Skip to content

Feat/memory primitive #613

Feat/memory primitive

Feat/memory primitive #613

Workflow file for this run

name: QA
on:
pull_request:
push:
branches: [main, feat/*]
workflow_dispatch:
# Cancel in-progress runs on same PR/branch
concurrency:
group: qa-${{ github.ref }}
cancel-in-progress: true
jobs:
#############################################################################
# PYTHON JOBS - Matrix strategy for all packages
#############################################################################
python-packages:
name: Python ${{ matrix.package.name }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package:
- name: "Events"
path: "lib/python/agentic_events"
python: "3.11"
pytest-args: "-x -q"
- name: "Isolation"
path: "lib/python/agentic_isolation"
python: "3.11"
pytest-args: "-x -q --ignore=tests/integration"
- name: "Logging"
path: "lib/python/agentic_logging"
python: "3.11"
pytest-args: "-x -q --cov-fail-under=90"
typecheck: true
steps:
- uses: actions/checkout@v6
- name: Setup UV
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "${{ matrix.package.path }}/uv.lock"
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.package.python }}
- name: Install dependencies
working-directory: ./${{ matrix.package.path }}
run: uv sync --all-extras
- name: Check formatting
working-directory: ./${{ matrix.package.path }}
run: uv run ruff format --check .
- name: Lint
working-directory: ./${{ matrix.package.path }}
run: uv run ruff check .
- name: Type check
if: matrix.package.typecheck
working-directory: ./${{ matrix.package.path }}
run: uv run mypy .
- name: Run tests
working-directory: ./${{ matrix.package.path }}
run: uv run pytest ${{ matrix.package.pytest-args }}
python-hooks:
name: Python Hooks & Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup UV
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Check Hook Files
run: |
uv tool install ruff
uv tool run ruff format --check plugins/sdlc/hooks/**/*.py plugins/workspace/hooks/handlers/*.py
uv tool run ruff check plugins/sdlc/hooks/**/*.py plugins/workspace/hooks/handlers/*.py
- name: Run Unit Tests
working-directory: ./tests/unit/claude/hooks
run: |
uv sync --all-extras
uv run pytest -x -q
consumer-contracts:
name: Consumer Contract Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup UV
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "tests/consumer_contracts/uv.lock"
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install dependencies
working-directory: ./tests/consumer_contracts
run: uv sync --all-extras
- name: Run contract tests
working-directory: ./tests/consumer_contracts
run: uv run pytest -v
#############################################################################
# PLUGIN STRUCTURE VALIDATION - Ensure plugins work in local + Docker
#############################################################################
plugin-validate:
name: Plugin Validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Validate plugin structure
run: |
failed=0
total_pass=0
total_warn=0
total_error=0
for plugin_dir in plugins/*/; do
plugin_name=$(basename "$plugin_dir")
plugin_json="${plugin_dir}.claude-plugin/plugin.json"
hooks_json="${plugin_dir}hooks/hooks.json"
errors=0
warnings=0
passes=0
echo ""
echo "═══════════════════════════════════════════════════"
echo "Validating plugin: ${plugin_name}"
echo "═══════════════════════════════════════════════════"
# --- Structure checks ---
# 1. Manifest exists
if [[ ! -f "$plugin_json" ]]; then
echo " [ERROR] .claude-plugin/plugin.json missing"
errors=$((errors + 1))
echo ""
echo " Result: ${passes} passed, ${warnings} warnings, ${errors} errors"
total_error=$((total_error + errors))
failed=1
continue
fi
echo " [PASS] .claude-plugin/plugin.json exists"
passes=$((passes + 1))
# 2. Manifest is valid JSON
if ! jq empty "$plugin_json" 2>/dev/null; then
echo " [ERROR] plugin.json is not valid JSON"
errors=$((errors + 1))
total_error=$((total_error + errors))
failed=1
continue
fi
echo " [PASS] plugin.json is valid JSON"
passes=$((passes + 1))
# 3. Required fields
name_field=$(jq -r '.name // empty' "$plugin_json")
version_field=$(jq -r '.version // empty' "$plugin_json")
desc_field=$(jq -r '.description // empty' "$plugin_json")
if [[ -z "$name_field" ]] || [[ -z "$version_field" ]] || [[ -z "$desc_field" ]]; then
echo " [ERROR] Missing required fields (name, version, or description)"
errors=$((errors + 1))
else
echo " [PASS] Required fields present (name: ${name_field}, version: ${version_field})"
passes=$((passes + 1))
fi
# --- Hooks checks ---
if [[ -f "$hooks_json" ]]; then
# 4. hooks.json is valid JSON
if ! jq empty "$hooks_json" 2>/dev/null; then
echo " [ERROR] hooks/hooks.json is not valid JSON"
errors=$((errors + 1))
else
echo " [PASS] hooks/hooks.json is valid JSON"
passes=$((passes + 1))
fi
# 5. No parent traversal in hook commands
if grep -q '\.\.\/' "$hooks_json"; then
echo " [ERROR] hooks.json contains ../ parent traversal (breaks --plugin-dir)"
errors=$((errors + 1))
else
echo " [PASS] No parent traversal (../) in hook paths"
passes=$((passes + 1))
fi
# 6. No absolute paths (should use ${CLAUDE_PLUGIN_ROOT})
if grep -Eq '"command":\s*"/[^$]' "$hooks_json"; then
echo " [ERROR] hooks.json contains absolute paths (must use \${CLAUDE_PLUGIN_ROOT})"
errors=$((errors + 1))
else
echo " [PASS] No hardcoded absolute paths in hook commands"
passes=$((passes + 1))
fi
# 7. Uses CLAUDE_PLUGIN_ROOT
if grep -q 'CLAUDE_PLUGIN_ROOT' "$hooks_json"; then
echo " [PASS] Hook commands use \${CLAUDE_PLUGIN_ROOT}"
passes=$((passes + 1))
else
echo " [WARN] No \${CLAUDE_PLUGIN_ROOT} references found in hooks.json"
warnings=$((warnings + 1))
fi
# 8. All referenced handler files exist
handler_missing=0
while IFS= read -r cmd_path; do
# Resolve ${CLAUDE_PLUGIN_ROOT} to the plugin dir for checking
resolved=$(echo "$cmd_path" | sed "s|\\\${CLAUDE_PLUGIN_ROOT}|${plugin_dir%/}|g")
if [[ ! -f "$resolved" ]]; then
echo " [ERROR] Hook handler not found: ${cmd_path} (resolved: ${resolved})"
errors=$((errors + 1))
handler_missing=1
fi
done < <(jq -r '.. | .command? // empty' "$hooks_json" 2>/dev/null)
if [[ "$handler_missing" -eq 0 ]]; then
echo " [PASS] All hook handlers exist on disk"
passes=$((passes + 1))
fi
fi
# --- Self-containment: check Python handlers ---
while IFS= read -r pyfile; do
# Check for EventEmitter writing to stdout (should be stderr)
if grep -q 'EventEmitter(' "$pyfile" && grep -q 'output=sys.stdout' "$pyfile"; then
echo " [ERROR] ${pyfile##*/}: EventEmitter outputs to sys.stdout (must use sys.stderr)"
errors=$((errors + 1))
fi
# Check for fail-open pattern (handlers should catch exceptions)
if grep -q 'def main' "$pyfile"; then
if ! grep -q 'except Exception' "$pyfile" && ! grep -q 'except:' "$pyfile"; then
echo " [WARN] ${pyfile##*/}: main() does not have top-level exception handler (should fail open)"
warnings=$((warnings + 1))
fi
fi
done < <(find "${plugin_dir}hooks" -name "*.py" -type f 2>/dev/null)
# --- requires_env validation ---
has_requires_env=$(jq -r 'has("requires_env")' "$plugin_json" 2>/dev/null)
if [[ "$has_requires_env" == "true" ]]; then
# Validate each entry has required fields
env_valid=true
while IFS= read -r env_var; do
has_desc=$(jq -r ".requires_env[\"${env_var}\"] | has(\"description\")" "$plugin_json")
has_req=$(jq -r ".requires_env[\"${env_var}\"] | has(\"required\")" "$plugin_json")
has_sec=$(jq -r ".requires_env[\"${env_var}\"] | has(\"secret\")" "$plugin_json")
if [[ "$has_desc" != "true" ]] || [[ "$has_req" != "true" ]] || [[ "$has_sec" != "true" ]]; then
echo " [ERROR] requires_env.${env_var}: missing description, required, or secret field"
errors=$((errors + 1))
env_valid=false
fi
done < <(jq -r '.requires_env | keys[]' "$plugin_json" 2>/dev/null)
if [[ "$env_valid" == "true" ]]; then
echo " [PASS] requires_env entries are well-formed"
passes=$((passes + 1))
fi
else
echo " [INFO] No requires_env declared"
fi
# --- Summary for this plugin ---
echo ""
echo " Result: ${passes} passed, ${warnings} warnings, ${errors} errors"
total_pass=$((total_pass + passes))
total_warn=$((total_warn + warnings))
total_error=$((total_error + errors))
if [[ "$errors" -gt 0 ]]; then
failed=1
fi
done
echo ""
echo "═══════════════════════════════════════════════════"
echo "Total: ${total_pass} passed, ${total_warn} warnings, ${total_error} errors"
echo "═══════════════════════════════════════════════════"
if [[ "$failed" -eq 1 ]]; then
echo ""
echo "ERROR: Plugin validation failed. Fix errors above before merging."
echo "See ADR-033 for plugin structure requirements."
exit 1
fi
- name: Write summary
if: always()
run: |
echo "## Plugin Validation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Plugin | Status |" >> $GITHUB_STEP_SUMMARY
echo "|--------|--------|" >> $GITHUB_STEP_SUMMARY
for plugin_dir in plugins/*/; do
plugin_name=$(basename "$plugin_dir")
plugin_json="${plugin_dir}.claude-plugin/plugin.json"
if [[ -f "$plugin_json" ]]; then
version=$(jq -r '.version // "?"' "$plugin_json")
echo "| ${plugin_name} (v${version}) | Checked |" >> $GITHUB_STEP_SUMMARY
else
echo "| ${plugin_name} | No manifest |" >> $GITHUB_STEP_SUMMARY
fi
done
#############################################################################
# PLUGIN VERSION CHECK - Ensure plugin.json version is bumped on changes
#############################################################################
plugin-version-check:
name: Plugin Version Check
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check plugin versions
run: |
base="${{ github.event.pull_request.base.sha }}"
failed=0
for plugin_dir in plugins/*/; do
plugin_name=$(basename "$plugin_dir")
plugin_json="${plugin_dir}.claude-plugin/plugin.json"
# Skip plugins without plugin.json
if [[ ! -f "$plugin_json" ]]; then
continue
fi
# Check if any files in this plugin changed (excluding plugin.json itself)
changed_files=$(git diff --name-only "$base"...HEAD -- "$plugin_dir" | grep -v '.claude-plugin/plugin.json' || true)
if [[ -z "$changed_files" ]]; then
echo "✓ $plugin_name — no content changes"
continue
fi
# Get version from base and head
head_version=$(jq -r '.version' "$plugin_json")
base_version=$(git show "${base}:${plugin_json}" 2>/dev/null | jq -r '.version' 2>/dev/null || echo "")
if [[ -z "$base_version" ]]; then
echo "✓ $plugin_name — new plugin (no base version)"
continue
fi
if [[ "$base_version" == "$head_version" ]]; then
echo ""
echo "✗ $plugin_name — content changed but version not bumped (${base_version})"
echo " Changed files:"
echo "$changed_files" | sed 's/^/ /'
echo " → Update version in ${plugin_json}"
echo ""
failed=1
else
echo "✓ $plugin_name — version bumped ${base_version} → ${head_version}"
fi
done
if [[ "$failed" -eq 1 ]]; then
echo ""
echo "ERROR: Plugin content changed without version bump."
echo "Claude Code uses the version field in plugin.json to detect updates."
echo "Without a bump, users running 'claude plugin update' won't get the new code."
exit 1
fi
echo ""
echo "All plugin versions OK."
#############################################################################
# SUMMARY JOB - Required status check for PRs
#############################################################################
qa-success:
name: QA Success
if: always()
needs:
- python-packages
- python-hooks
- consumer-contracts
- plugin-validate
- plugin-version-check
runs-on: ubuntu-latest
steps:
- name: Check all jobs
run: |
if [[ "${{ needs.python-packages.result }}" == "failure" ]] || \
[[ "${{ needs.python-hooks.result }}" == "failure" ]] || \
[[ "${{ needs.consumer-contracts.result }}" == "failure" ]] || \
[[ "${{ needs.plugin-validate.result }}" == "failure" ]] || \
[[ "${{ needs.plugin-version-check.result }}" == "failure" ]]; then
echo "QA checks failed"
exit 1
fi
if [[ "${{ needs.python-packages.result }}" == "cancelled" ]] || \
[[ "${{ needs.python-hooks.result }}" == "cancelled" ]] || \
[[ "${{ needs.consumer-contracts.result }}" == "cancelled" ]] || \
[[ "${{ needs.plugin-validate.result }}" == "cancelled" ]] || \
[[ "${{ needs.plugin-version-check.result }}" == "cancelled" ]]; then
echo "QA checks were cancelled"
exit 1
fi
echo "All QA checks passed!"
- name: Write summary
run: |
echo "## QA Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Python Packages | ${{ needs.python-packages.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Python Hooks | ${{ needs.python-hooks.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Consumer Contracts | ${{ needs.consumer-contracts.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Plugin Validation | ${{ needs.plugin-validate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Plugin Version Check | ${{ needs.plugin-version-check.result }} |" >> $GITHUB_STEP_SUMMARY