From 0f15b3c2dd6b7e17539cff82ab750ff59344844d Mon Sep 17 00:00:00 2001 From: bluejayA Date: Tue, 14 Apr 2026 10:42:04 +0900 Subject: [PATCH 1/2] perf(ci): scope remote audit to changed skills via revision diff Large plugin updates were timing out because every skill in the plugin was re-audited even when only a few actually changed. Use the old/new revision pair from marketplace.json to `git diff` the plugin repo and audit only the skills whose files changed. Shared-path (`skills/_*`) changes fall back to a full audit to preserve correctness. Also add a 300s per-skill timeout so a single hung audit cannot stall the job. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/skill-audit-remote.yml | 81 ++++++++++++++++++++---- 1 file changed, 70 insertions(+), 11 deletions(-) diff --git a/.github/workflows/skill-audit-remote.yml b/.github/workflows/skill-audit-remote.yml index 8f99da2..d64bfcd 100644 --- a/.github/workflows/skill-audit-remote.yml +++ b/.github/workflows/skill-audit-remote.yml @@ -82,6 +82,7 @@ jobs: HEAD_MAP=$(jq '[.plugins[] | {key: .name, value: .}] | from_entries' .claude-plugin/marketplace.json) # 새로 추가되거나 version/revision/source가 변경된 플러그인 추출 + # old_revision도 함께 포함시켜 audit 단계에서 diff-scoping에 사용 jq -n \ --argjson base "$BASE_MAP" \ --argjson head "$HEAD_MAP" \ @@ -91,7 +92,7 @@ jobs: (.value.version != $base[.key].version) or (.value.revision != $base[.key].revision) or (.value.source.url != $base[.key].source.url) - ) | .value + ) | .value + {old_revision: ($base[.key].revision // null)} ]' > /tmp/changed-plugins.json COUNT=$(jq 'length' /tmp/changed-plugins.json) @@ -151,6 +152,7 @@ jobs: PLUGIN_NAME=$(jq -r ".[$i].name" /tmp/changed-plugins.json) URL=$(jq -r ".[$i].source.url" /tmp/changed-plugins.json) REVISION=$(jq -r ".[$i].revision // \"\"" /tmp/changed-plugins.json) + OLD_REVISION=$(jq -r ".[$i].old_revision // \"\"" /tmp/changed-plugins.json) echo "::group::Processing plugin: $PLUGIN_NAME" @@ -180,32 +182,89 @@ jobs: cd ../.. fi - # 스킬 디렉토리 탐색 - SKILL_DIRS=$(find "_plugins/$PLUGIN_NAME/skills" -name "SKILL.md" -maxdepth 2 2>/dev/null \ - | xargs -I{} dirname {} \ - | sort -u) + # ── 스킬 디렉토리 선별 (diff-scoping) ── + # OLD_REVISION이 있으면 old..new 변경된 스킬만, 없으면(신규 플러그인) 전수 감사. + # skills/_shared/ 또는 skills/_* 공유 경로가 바뀌면 전수 감사로 fallback. + SCOPE_MODE="full" + SKILL_DIRS="" + if [ -n "$OLD_REVISION" ] && [ "$OLD_REVISION" != "null" ] && [ "$OLD_REVISION" != "$REVISION" ]; then + (cd "_plugins/$PLUGIN_NAME" && git fetch origin "$OLD_REVISION" --depth 1 2>&1) \ + && DIFF_OK=1 || DIFF_OK=0 + + if [ "$DIFF_OK" = "1" ]; then + CHANGED_PATHS=$(cd "_plugins/$PLUGIN_NAME" \ + && git diff --name-only "$OLD_REVISION" "$REVISION" -- 'skills/' 2>/dev/null) + + # 공유 경로(skills/_*) 변경 시 전수 감사로 fallback + SHARED_CHANGED=$(echo "$CHANGED_PATHS" | awk -F/ 'NF>=2 && $2 ~ /^_/' | head -1) + + if [ -n "$SHARED_CHANGED" ]; then + echo "::notice::$PLUGIN_NAME: shared skills path changed ($SHARED_CHANGED) → auditing all skills" + SCOPE_MODE="full (shared-path changed)" + else + CHANGED_DIRS=$(echo "$CHANGED_PATHS" \ + | awk -F/ 'NF>=2 && $2 !~ /^_/ {print $1"/"$2}' \ + | sort -u) + + for REL in $CHANGED_DIRS; do + if [ -f "_plugins/$PLUGIN_NAME/$REL/SKILL.md" ]; then + SKILL_DIRS="${SKILL_DIRS}_plugins/$PLUGIN_NAME/$REL"$'\n' + fi + done + SKILL_DIRS=$(echo "$SKILL_DIRS" | sed '/^$/d') + SCOPE_MODE="diff ($OLD_REVISION..$REVISION)" + fi + else + echo "::warning::$PLUGIN_NAME: failed to fetch old revision $OLD_REVISION → falling back to full audit" + SCOPE_MODE="full (old-rev fetch failed)" + fi + else + echo "::notice::$PLUGIN_NAME: no old_revision (new plugin) → full audit" + fi + + # full 모드이거나 diff가 비어있으면 전수 탐색 + if [ "$SCOPE_MODE" != "diff ($OLD_REVISION..$REVISION)" ]; then + SKILL_DIRS=$(find "_plugins/$PLUGIN_NAME/skills" -name "SKILL.md" -maxdepth 2 2>/dev/null \ + | xargs -I{} dirname {} \ + | sort -u) + fi if [ -z "$SKILL_DIRS" ]; then - echo "::warning::No skills found in $PLUGIN_NAME" - REPORT="${REPORT}$(printf '\n## ⚠️ %s\nNo SKILL.md files found under skills/ directory.\n' "$PLUGIN_NAME")" + if [[ "$SCOPE_MODE" == diff* ]]; then + echo "::notice::$PLUGIN_NAME: no skill changes in diff — skipping audit" + REPORT="${REPORT}$(printf '\n## ℹ️ %s\nNo skill code changes detected in %s..%s — audit skipped.\n' "$PLUGIN_NAME" "${OLD_REVISION:0:7}" "${REVISION:0:7}")" + else + echo "::warning::No skills found in $PLUGIN_NAME" + REPORT="${REPORT}$(printf '\n## ⚠️ %s\nNo SKILL.md files found under skills/ directory.\n' "$PLUGIN_NAME")" + fi echo "::endgroup::" continue fi + echo "Audit scope: $SCOPE_MODE" + echo "Skills to audit ($(echo "$SKILL_DIRS" | wc -l | tr -d ' ')):" + echo "$SKILL_DIRS" + # 각 스킬 감사 for SKILL_DIR in $SKILL_DIRS; do SKILL_NAME=$(basename "$SKILL_DIR") echo "Auditing: $PLUGIN_NAME/$SKILL_NAME" - RESULT=$(claude --print \ + # per-skill 타임아웃 300초 (timeout exit code 124 = 상한 초과) + RESULT=$(timeout 300 claude --print \ "${AUDIT_TOOL_PATH} 스킬을 사용하여 ${SKILL_DIR} 스킬을 검사해줘. PR 제출자: ${PR_ACTOR}, Ruleset version: ${RULESET_VERSION}, Ruleset SHA: ${RULESET_SHA}. 플러그인: ${PLUGIN_NAME}, Revision: ${REVISION:-HEAD}. JSON 결과도 함께 출력해줘." \ 2>&1) CLAUDE_EXIT=$? - # Fail-Closed: claude CLI 실패 시 BLOCKED 처리 + # Fail-Closed: claude CLI 실패 / 타임아웃 시 BLOCKED 처리 if [ $CLAUDE_EXIT -ne 0 ] || [ -z "$RESULT" ]; then - echo "::error::Claude CLI failed for $PLUGIN_NAME/$SKILL_NAME (exit code: $CLAUDE_EXIT)" - REPORT="${REPORT}$(printf '\n## ❌ %s/%s\nAudit execution failed (exit code: %s). Treating as BLOCKED for safety.\n' "$PLUGIN_NAME" "$SKILL_NAME" "$CLAUDE_EXIT")" + if [ $CLAUDE_EXIT -eq 124 ]; then + echo "::error::Claude CLI timed out (>300s) for $PLUGIN_NAME/$SKILL_NAME" + REPORT="${REPORT}$(printf '\n## ❌ %s/%s\nAudit timed out after 300s. Treating as BLOCKED for safety.\n' "$PLUGIN_NAME" "$SKILL_NAME")" + else + echo "::error::Claude CLI failed for $PLUGIN_NAME/$SKILL_NAME (exit code: $CLAUDE_EXIT)" + REPORT="${REPORT}$(printf '\n## ❌ %s/%s\nAudit execution failed (exit code: %s). Treating as BLOCKED for safety.\n' "$PLUGIN_NAME" "$SKILL_NAME" "$CLAUDE_EXIT")" + fi OVERALL_VERDICT="BLOCKED" continue fi From 6768fe74d968de5cb17019a40eeaf3d906ad37f0 Mon Sep 17 00:00:00 2001 From: bluejayA Date: Tue, 14 Apr 2026 10:50:11 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(ci):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20SHA=20validation,=20stricter=20awk=20filter,=20SCOP?= =?UTF-8?q?E=5FKIND=20enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applying reviewer feedback on the diff-scoping patch: - Validate `revision` / `old_revision` fields as `^[a-f0-9]{7,40}$` before they reach `git fetch` / `git diff` (defense-in-depth). - Tighten awk path filter from `NF>=2` to `NF>=3` so that files sitting directly under `skills/` (e.g. `skills/README.md`) no longer pollute `CHANGED_DIRS` or `SHARED_CHANGED` detection. - Split `SCOPE_MODE` into `SCOPE_KIND` (enum for control flow) and `SCOPE_DESC` (human-readable label). The old string was used for both, so any future label tweak would silently break the full-fallback branch. Comparisons now use the enum only. - Convert `cmd && A || B` DIFF_OK assignment to `if/then/else` for readability. - Replace `wc -l | tr -d ' '` skill count with `grep -c .` for correct handling of trailing-newline-less input. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/skill-audit-remote.yml | 63 ++++++++++++++++++------ 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/.github/workflows/skill-audit-remote.yml b/.github/workflows/skill-audit-remote.yml index d64bfcd..542c132 100644 --- a/.github/workflows/skill-audit-remote.yml +++ b/.github/workflows/skill-audit-remote.yml @@ -127,6 +127,28 @@ jobs: fi done + # ── revision SHA 형식 검증 (diff-scoping으로 shell에 흘러들기 전 방어) ── + - name: Validate revision SHAs + if: steps.detect.outputs.count != '0' + run: | + SHA_PATTERN='^[a-f0-9]{7,40}$' + + # new revision (필수) + jq -r '.[].revision // ""' /tmp/changed-plugins.json | while read -r REV; do + if [ -n "$REV" ] && ! echo "$REV" | grep -qE "$SHA_PATTERN"; then + echo "::error::Invalid revision SHA: $REV" + exit 1 + fi + done + + # old revision (있을 때만) + jq -r '.[].old_revision // ""' /tmp/changed-plugins.json | while read -r REV; do + if [ -n "$REV" ] && ! echo "$REV" | grep -qE "$SHA_PATTERN"; then + echo "::error::Invalid old_revision SHA: $REV" + exit 1 + fi + done + # ── 플러그인 클론 + 감사 ── - name: Install Claude CLI if: steps.detect.outputs.count != '0' @@ -185,25 +207,35 @@ jobs: # ── 스킬 디렉토리 선별 (diff-scoping) ── # OLD_REVISION이 있으면 old..new 변경된 스킬만, 없으면(신규 플러그인) 전수 감사. # skills/_shared/ 또는 skills/_* 공유 경로가 바뀌면 전수 감사로 fallback. - SCOPE_MODE="full" + # + # SCOPE_KIND: enum — 제어 흐름용 (diff / full / full_shared / full_fetch_failed / full_new_plugin) + # SCOPE_DESC: 사람이 읽는 라벨 — 로그/리포트용만 사용 + SCOPE_KIND="full_new_plugin" + SCOPE_DESC="full (no old revision)" SKILL_DIRS="" if [ -n "$OLD_REVISION" ] && [ "$OLD_REVISION" != "null" ] && [ "$OLD_REVISION" != "$REVISION" ]; then - (cd "_plugins/$PLUGIN_NAME" && git fetch origin "$OLD_REVISION" --depth 1 2>&1) \ - && DIFF_OK=1 || DIFF_OK=0 + if (cd "_plugins/$PLUGIN_NAME" && git fetch origin "$OLD_REVISION" --depth 1 2>&1); then + DIFF_OK=1 + else + DIFF_OK=0 + fi if [ "$DIFF_OK" = "1" ]; then CHANGED_PATHS=$(cd "_plugins/$PLUGIN_NAME" \ && git diff --name-only "$OLD_REVISION" "$REVISION" -- 'skills/' 2>/dev/null) - # 공유 경로(skills/_*) 변경 시 전수 감사로 fallback - SHARED_CHANGED=$(echo "$CHANGED_PATHS" | awk -F/ 'NF>=2 && $2 ~ /^_/' | head -1) + # 공유 경로(skills/_*/) 변경 시 전수 감사로 fallback + # NF>=3: skills/_shared/x.md 같은 파일만 매치 (skills/ 직하 파일/디렉토리 제외) + SHARED_CHANGED=$(echo "$CHANGED_PATHS" | awk -F/ 'NF>=3 && $2 ~ /^_/' | head -1) if [ -n "$SHARED_CHANGED" ]; then echo "::notice::$PLUGIN_NAME: shared skills path changed ($SHARED_CHANGED) → auditing all skills" - SCOPE_MODE="full (shared-path changed)" + SCOPE_KIND="full_shared" + SCOPE_DESC="full (shared-path changed)" else + # NF>=3: skills//... 경로만 (skills/top-level.md 같은 직하 파일 제외) CHANGED_DIRS=$(echo "$CHANGED_PATHS" \ - | awk -F/ 'NF>=2 && $2 !~ /^_/ {print $1"/"$2}' \ + | awk -F/ 'NF>=3 && $2 !~ /^_/ {print $1"/"$2}' \ | sort -u) for REL in $CHANGED_DIRS; do @@ -212,25 +244,27 @@ jobs: fi done SKILL_DIRS=$(echo "$SKILL_DIRS" | sed '/^$/d') - SCOPE_MODE="diff ($OLD_REVISION..$REVISION)" + SCOPE_KIND="diff" + SCOPE_DESC="diff (${OLD_REVISION:0:7}..${REVISION:0:7})" fi else echo "::warning::$PLUGIN_NAME: failed to fetch old revision $OLD_REVISION → falling back to full audit" - SCOPE_MODE="full (old-rev fetch failed)" + SCOPE_KIND="full_fetch_failed" + SCOPE_DESC="full (old-rev fetch failed)" fi else echo "::notice::$PLUGIN_NAME: no old_revision (new plugin) → full audit" fi - # full 모드이거나 diff가 비어있으면 전수 탐색 - if [ "$SCOPE_MODE" != "diff ($OLD_REVISION..$REVISION)" ]; then + # diff 모드가 아니면 전수 탐색 + if [ "$SCOPE_KIND" != "diff" ]; then SKILL_DIRS=$(find "_plugins/$PLUGIN_NAME/skills" -name "SKILL.md" -maxdepth 2 2>/dev/null \ | xargs -I{} dirname {} \ | sort -u) fi if [ -z "$SKILL_DIRS" ]; then - if [[ "$SCOPE_MODE" == diff* ]]; then + if [ "$SCOPE_KIND" = "diff" ]; then echo "::notice::$PLUGIN_NAME: no skill changes in diff — skipping audit" REPORT="${REPORT}$(printf '\n## ℹ️ %s\nNo skill code changes detected in %s..%s — audit skipped.\n' "$PLUGIN_NAME" "${OLD_REVISION:0:7}" "${REVISION:0:7}")" else @@ -241,8 +275,9 @@ jobs: continue fi - echo "Audit scope: $SCOPE_MODE" - echo "Skills to audit ($(echo "$SKILL_DIRS" | wc -l | tr -d ' ')):" + SKILL_COUNT=$(echo "$SKILL_DIRS" | grep -c .) + echo "Audit scope: $SCOPE_DESC" + echo "Skills to audit ($SKILL_COUNT):" echo "$SKILL_DIRS" # 각 스킬 감사