diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 3c641a2..022a7fc 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -1,7 +1,14 @@ name: Validate PR on: pull_request: - paths: ['skillshare-hub.json'] + paths: + - 'skillshare-hub.json' + - 'scripts/**' + - '.github/workflows/validate-pr.yml' + +permissions: + contents: read + pull-requests: write jobs: validate: @@ -10,32 +17,7 @@ jobs: - uses: actions/checkout@v4 - name: Validate hub.json - run: | - # Check valid JSON - jq empty skillshare-hub.json || exit 1 - - # Check required fields - missing=$(jq -r '.skills[] | select(.name == "" or .name == null or .description == "" or .description == null or .source == "" or .source == null) | .name // "unnamed"' skillshare-hub.json) - if [ -n "$missing" ]; then - echo "ERROR: Skills missing required fields: $missing" - exit 1 - fi - - # Check no duplicate names - dupes=$(jq -r '[.skills[].name] | group_by(.) | map(select(length > 1)) | flatten | .[]' skillshare-hub.json) - if [ -n "$dupes" ]; then - echo "ERROR: Duplicate skill names: $dupes" - exit 1 - fi - - # Check name format (lowercase, hyphens only) - bad_names=$(jq -r '.skills[].name | select(test("^[a-z0-9][a-z0-9-]*$") | not)' skillshare-hub.json) - if [ -n "$bad_names" ]; then - echo "ERROR: Invalid skill names (must be lowercase, hyphens): $bad_names" - exit 1 - fi - - echo "Validation passed: $(jq '.skills | length' skillshare-hub.json) skills" + run: ./scripts/validate.sh audit: needs: validate @@ -45,80 +27,41 @@ jobs: with: fetch-depth: 0 - - name: Find new/changed skill sources - id: diff + - name: Install skillshare + run: | + curl -fsSL https://raw.githubusercontent.com/runkids/skillshare/main/install.sh | sh + skillshare version + skillshare init -g --no-copy --no-targets --no-git --no-skill + + - name: Audit new skills + id: audit env: BASE_SHA: ${{ github.event.pull_request.base.sha }} + CI: "true" + AUDIT_REPORT: /tmp/audit-report.md run: | - # Get base version sources (empty if file doesn't exist in base) - base_sources=$(git show "${BASE_SHA}:skillshare-hub.json" 2>/dev/null \ - | jq -r '.skills[] | "\(.name)|\(.source)"' | sort) || base_sources="" - - pr_sources=$(jq -r '.skills[] | "\(.name)|\(.source)"' skillshare-hub.json | sort) - - # Find new or changed entries (name|source pairs not in base) - new_sources=$(comm -13 <(echo "$base_sources") <(echo "$pr_sources") | cut -d'|' -f2) - - if [ -z "$new_sources" ]; then - echo "No new or changed skill sources to audit" - echo "has_new=false" >> "$GITHUB_OUTPUT" - else - echo "New/changed sources to audit:" - echo "$new_sources" - echo "$new_sources" > /tmp/new-sources.txt - echo "has_new=true" >> "$GITHUB_OUTPUT" - fi + ./scripts/audit.sh || echo "audit_failed=true" >> "$GITHUB_OUTPUT" + [ -f /tmp/audit-report.md ] && echo "has_report=true" >> "$GITHUB_OUTPUT" - - name: Download skillshare - if: steps.diff.outputs.has_new == 'true' + - name: Comment PR with audit results + if: always() && steps.audit.outputs.has_report == 'true' env: GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | - gh release download --repo runkids/skillshare \ - --pattern 'skillshare_*_linux_amd64.tar.gz' \ - --output /tmp/skillshare.tar.gz - tar xzf /tmp/skillshare.tar.gz -C /usr/local/bin skillshare - skillshare version - - - name: Audit new skills - if: steps.diff.outputs.has_new == 'true' - run: | - failed=0 - while IFS= read -r source; do - [ -z "$source" ] && continue - - # Determine clone URL - if [[ "$source" == http* ]]; then - clone_url="$source" - else - clone_url="https://github.com/${source}.git" - fi - - safe_name=$(echo "$source" | tr '/' '-') - clone_dir="/tmp/audit-${safe_name}" - - echo "::group::Auditing ${source}" - - if ! git clone --depth 1 "$clone_url" "$clone_dir" 2>&1; then - echo "::error::Failed to clone ${source}" - failed=1 - echo "::endgroup::" - continue - fi - - if ! skillshare audit "$clone_dir" --threshold high; then - echo "::error::Security audit failed for ${source}" - failed=1 - fi - - rm -rf "$clone_dir" - echo "::endgroup::" - done < /tmp/new-sources.txt - - if [ "$failed" -ne 0 ]; then - echo "" - echo "::error::One or more skills failed the security audit. Please fix the flagged issues and push again." - exit 1 - fi - - echo "All new skills passed the security audit" + # Delete previous audit comment if exists + gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ + --jq '.[] | select(.body | startswith("## Skill Audit Results")) | .id' \ + | while read -r id; do + gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${id}" + done + + gh pr comment "${PR_NUMBER}" --body-file /tmp/audit-report.md + + - name: Show audit summary + if: always() && steps.audit.outputs.has_report == 'true' + run: cat /tmp/audit-report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Fail if audit failed + if: steps.audit.outputs.audit_failed == 'true' + run: exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c5f206 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cad5348 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: format validate audit ci + +format: ## Format skillshare-hub.json (sort by name, 2-space indent) + @./scripts/format.sh + +validate: ## Validate skillshare-hub.json format and rules + @./scripts/validate.sh + +audit: ## Audit new/changed skills against main + @./scripts/audit.sh main + +ci: validate audit ## Run full CI locally diff --git a/scripts/audit.sh b/scripts/audit.sh new file mode 100755 index 0000000..8b86835 --- /dev/null +++ b/scripts/audit.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +HUB_FILE="skillshare-hub.json" +BASE_REF="${1:-${BASE_SHA:-main}}" +REPORT_FILE="${AUDIT_REPORT:-}" + +# Detect environment +is_ci() { [ "${CI:-}" = "true" ]; } +log_group() { is_ci && echo "::group::$1" || echo "=== $1 ==="; } +log_endgroup() { is_ci && echo "::endgroup::" || true; } +log_error() { is_ci && echo "::error::$1" || echo "ERROR: $1"; } + +# --- Find new/changed sources --- +base_sources=$(git show "${BASE_REF}:${HUB_FILE}" 2>/dev/null \ + | jq -r '.skills[] | "\(.name)|\(.source)"' | sort) || base_sources="" + +pr_sources=$(jq -r '.skills[] | "\(.name)|\(.source)"' "$HUB_FILE" | sort) + +new_entries=$(comm -13 <(echo "$base_sources") <(echo "$pr_sources")) +new_sources=$(echo "$new_entries" | cut -d'|' -f2 | sort -u) + +if [ -z "$new_entries" ]; then + echo "No new or changed skill sources to audit" + [ -n "$REPORT_FILE" ] && echo "No new or changed skill sources to audit." > "$REPORT_FILE" + exit 0 +fi + +echo "New/changed sources to audit:" +echo "$new_sources" +echo "" + +# --- Check skillshare is available --- +if ! command -v skillshare &>/dev/null; then + echo "ERROR: skillshare CLI not found. Install from https://github.com/runkids/skillshare" + exit 1 +fi + +# --- Audit each source --- +failed=0 +results=() +details=() + +while IFS= read -r source; do + [ -z "$source" ] && continue + + if [[ "$source" == http* ]]; then + clone_url="$source" + else + clone_url="https://github.com/${source}.git" + fi + + safe_name=$(echo "$source" | tr '/' '-') + clone_dir="/tmp/audit-${safe_name}" + + log_group "Auditing ${source}" + + if ! clone_output=$(git clone --depth 1 "$clone_url" "$clone_dir" 2>&1); then + log_error "Failed to clone ${source}" + results+=("| \`${source}\` | :x: Clone failed | - |") + details+=("DETAIL_SEP### \`${source}\`"$'\n'"Clone failed:"$'\n'"\`\`\`"$'\n'"${clone_output}"$'\n'"\`\`\`") + failed=1 + log_endgroup + continue + fi + + audit_output=$(skillshare audit "$clone_dir" --threshold high 2>&1 | sed 's/\x1b\[[0-9;]*m//g') || true + + echo "$audit_output" + + # Extract risk score and label + risk=$(echo "$audit_output" | sed -n 's/.*Risk: \([A-Z]* ([0-9]*\/[0-9]*)\).*/\1/p' | tail -1) + [ -z "$risk" ] && risk="N/A" + risk_label=$(echo "$risk" | awk '{print $1}') + + if echo "$audit_output" | grep -q "config not found"; then + results+=("| \`${source}\` | :x: No config | - |") + details+=("DETAIL_SEP### \`${source}\`"$'\n'"No skillshare config found. Run \`skillshare init\` in the source repo.") + log_error "No skillshare config found for ${source}. Run 'skillshare init' in the source repo." + failed=1 + elif [[ "$risk_label" == "HIGH" || "$risk_label" == "CRITICAL" ]]; then + results+=("| \`${source}\` | :x: Risk ${risk_label} | ${risk} |") + details+=("DETAIL_SEP### \`${source}\`"$'\n'"\`\`\`"$'\n'"${audit_output}"$'\n'"\`\`\`") + log_error "Risk ${risk_label} for ${source}" + failed=1 + else + results+=("| \`${source}\` | :white_check_mark: Passed | ${risk} |") + details+=("DETAIL_SEP### \`${source}\`"$'\n'"\`\`\`"$'\n'"${audit_output}"$'\n'"\`\`\`") + fi + + rm -rf "$clone_dir" + log_endgroup +done <<< "$new_sources" + +# --- Generate report --- +if [ -n "$REPORT_FILE" ]; then + { + echo "## Skill Audit Results" + echo "" + echo "| Source | Status | Risk |" + echo "|--------|--------|------|" + for row in "${results[@]}"; do + echo "$row" + done + echo "" + if [ "$failed" -ne 0 ]; then + echo "> **Failed**: One or more skills did not pass the security audit." + else + echo "> **All skills passed** the security audit." + fi + echo "" + echo "
" + echo "Audit Details" + echo "" + for detail in "${details[@]}"; do + echo "${detail}" | sed 's/^DETAIL_SEP//' + echo "" + done + echo "
" + } > "$REPORT_FILE" +fi + +if [ "$failed" -ne 0 ]; then + echo "" + log_error "One or more skills failed the security audit. Please fix the flagged issues and push again." + exit 1 +fi + +echo "All new skills passed the security audit" diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 0000000..9489406 --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +HUB_FILE="${1:-skillshare-hub.json}" + +if [ ! -f "$HUB_FILE" ]; then + echo "ERROR: $HUB_FILE not found" + exit 1 +fi + +jq '.skills |= sort_by(.name)' "$HUB_FILE" > "${HUB_FILE}.tmp" \ + && mv "${HUB_FILE}.tmp" "$HUB_FILE" + +echo "Formatted: $HUB_FILE ($(jq '.skills | length' "$HUB_FILE") skills, sorted by name)" diff --git a/scripts/validate.sh b/scripts/validate.sh new file mode 100755 index 0000000..f4c10f9 --- /dev/null +++ b/scripts/validate.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +HUB_FILE="${1:-skillshare-hub.json}" + +if [ ! -f "$HUB_FILE" ]; then + echo "ERROR: $HUB_FILE not found" + exit 1 +fi + +# Check valid JSON +if ! jq empty "$HUB_FILE" 2>/dev/null; then + echo "ERROR: Invalid JSON in $HUB_FILE" + exit 1 +fi +echo "OK: valid JSON" + +# Check required fields +missing=$(jq -r '.skills[] | select(.name == "" or .name == null or .description == "" or .description == null or .source == "" or .source == null) | .name // "unnamed"' "$HUB_FILE") +if [ -n "$missing" ]; then + echo "ERROR: Skills missing required fields: $missing" + exit 1 +fi +echo "OK: required fields present" + +# Check no duplicate names +dupes=$(jq -r '[.skills[].name] | group_by(.) | map(select(length > 1)) | flatten | .[]' "$HUB_FILE") +if [ -n "$dupes" ]; then + echo "ERROR: Duplicate skill names: $dupes" + exit 1 +fi +echo "OK: no duplicate names" + +# Check name format (lowercase, hyphens only) +bad_names=$(jq -r '.skills[].name | select(test("^[a-z0-9][a-z0-9-]*$") | not)' "$HUB_FILE") +if [ -n "$bad_names" ]; then + echo "ERROR: Invalid skill names (must be lowercase, hyphens): $bad_names" + exit 1 +fi +echo "OK: name format valid" + +# Check formatting (sorted by name, jq default 2-space indent) +expected=$(jq '.skills |= sort_by(.name)' "$HUB_FILE") +actual=$(cat "$HUB_FILE") +if [ "$expected" != "$actual" ]; then + echo "ERROR: $HUB_FILE is not formatted. Run: make format" + exit 1 +fi +echo "OK: formatting correct" + +echo "" +echo "Validation passed: $(jq '.skills | length' "$HUB_FILE") skills" diff --git a/skillshare-hub.json b/skillshare-hub.json index b93997e..08da980 100644 --- a/skillshare-hub.json +++ b/skillshare-hub.json @@ -6,7 +6,19 @@ "description": "Verify and fix ASCII box-drawing diagram alignment in markdown files", "source": "runkids/my-skills", "skill": "ascii-box-check", - "tags": ["docs", "workflow"] + "tags": [ + "docs", + "workflow" + ] + }, + { + "name": "skill-creator", + "description": "Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.", + "source": "anthropics/skills", + "skill": "skill-creator", + "tags": [ + "skill" + ] } ] }