Implements Tessl skill review CI/CD pipelines through an interactive, configuration-first wizard. Supports GitHub Actions, GitLab CI, Jenkins, Azure DevOps, and CircleCI.
94
Quality
90%
Does it follow best practices?
Impact
100%
1.75xAverage score across 5 eval scenarios
GitHub Actions implementation of Tessl skill review with score diff tracking and persistent caching. Two architecture options: single-workflow (internal repos) and two-workflow (public repos with fork contributions).
actions/setup-node)These placeholders are replaced with user's configuration:
{{TARGET_BRANCH}} → User's target branch (default: auto-detected from GitHub remote){{TRIGGER_PATHS}} → User's file paths (default: **/SKILL.md, **/skills/**){{CACHE_FILE}} → User's cache location (default: .github/.tessl/skill-review-cache.json){{AUTO_OPTIMIZE}} → Whether to auto-optimize skills (default: false).github/workflows/tessl-skill-review.ymlpull-requests: write permission.github/workflows/tessl-skill-review.yml (review + artifact).github/workflows/tessl-skill-review-comment.yml (post results)workflow_run trigger with secrets: inherit for securityFile: .github/workflows/tessl-skill-review.yml
name: Tessl Skill Review
on:
pull_request:
branches: [main]
paths:
- '**/SKILL.md'
- '**/skills/**'
- '.github/workflows/tessl-skill-review.yml'
push:
branches: [main]
paths:
- '**/SKILL.md'
- '**/skills/**'
workflow_dispatch:
permissions:
contents: write # Required for cache commits
pull-requests: write # Required for PR comments
jobs:
review-skills:
name: Review Skills
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Tessl CLI
run: npm install -g @tessl/cli
- name: Detect changed skills
id: detect
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
run: |
if [[ "$EVENT_NAME" == "pull_request" ]]; then
CHANGED_SKILLS=$(git diff --name-only --diff-filter=ACMR \
"origin/${BASE_REF}"...HEAD \
-- '**/SKILL.md' '**/skills/**' | \
grep 'SKILL.md$' | \
xargs -I {} dirname {} | \
sort -u)
else
# workflow_dispatch or push: find all skills
CHANGED_SKILLS=$(find . -name "SKILL.md" -not -path "./node_modules/*" -not -path "./.git/*" | \
xargs -I {} dirname {} | \
sed 's|^\./||' | \
sort -u)
fi
if [[ -z "$CHANGED_SKILLS" ]]; then
echo "No skill changes detected."
echo "skills=" >> "$GITHUB_OUTPUT"
else
echo "Skills to review:"
echo "$CHANGED_SKILLS"
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "skills<<${EOF_MARKER}" >> "$GITHUB_OUTPUT"
echo "$CHANGED_SKILLS" >> "$GITHUB_OUTPUT"
echo "${EOF_MARKER}" >> "$GITHUB_OUTPUT"
fi
- name: Read review cache
if: steps.detect.outputs.skills != ''
id: cache
run: |
CACHE_FILE=".github/.tessl/skill-review-cache.json"
if [[ -f "$CACHE_FILE" ]]; then
echo "Cache file found, loading..."
if CACHE_CONTENT=$(cat "$CACHE_FILE" 2>&1); then
# Validate JSON
if echo "$CACHE_CONTENT" | jq empty 2>/dev/null; then
echo "cache_exists=true" >> "$GITHUB_OUTPUT"
# Export cache to environment for review step
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "REVIEW_CACHE<<${EOF_MARKER}" >> "$GITHUB_ENV"
echo "$CACHE_CONTENT" >> "$GITHUB_ENV"
echo "${EOF_MARKER}" >> "$GITHUB_ENV"
else
echo "::warning::Cache file is invalid JSON, ignoring"
echo "cache_exists=false" >> "$GITHUB_OUTPUT"
fi
else
echo "::warning::Cache file exists but cannot be read: $CACHE_CONTENT"
echo "cache_exists=false" >> "$GITHUB_OUTPUT"
fi
else
echo "No cache file found, will create new one"
echo "cache_exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Run skill reviews
if: steps.detect.outputs.skills != ''
id: review
env:
SKILLS: ${{ steps.detect.outputs.skills }}
TESSL_API_KEY: ${{ secrets.TESSL_API_KEY }}
AUTO_OPTIMIZE: ${{ vars.AUTO_OPTIMIZE || 'false' }}
run: |
FAILED=0
TABLE="| Skill | Status | Review Score | Change |"
TABLE="${TABLE}\n|-------|--------|--------------|--------|"
DETAILS=""
# Create temporary file for cache entries
CACHE_FILE_TEMP=$(mktemp)
echo "Cache entries file: $CACHE_FILE_TEMP"
while IFS= read -r dir; do
[[ -z "$dir" ]] && continue
echo "::group::Reviewing $dir"
# Build review command based on auto-optimize setting
REVIEW_CMD="tessl skill review --json"
if [[ "${AUTO_OPTIMIZE:-false}" == "true" ]]; then
REVIEW_CMD="tessl skill review --json --optimize --yes"
fi
JSON_OUTPUT=$($REVIEW_CMD "$dir" 2>&1)
echo "$JSON_OUTPUT"
echo "::endgroup::"
# Extract JSON (skip everything before first '{')
JSON=$(echo "$JSON_OUTPUT" | sed -n '/{/,$p')
# Look up previous score from cache
PREV_SCORE=""
PREV_DESC=""
PREV_CONTENT=""
if [[ -n "$REVIEW_CACHE" ]]; then
CACHE_ENTRY=$(echo "$REVIEW_CACHE" | jq -r --arg path "$dir" '.skills[$path] // empty')
if [[ -n "$CACHE_ENTRY" ]]; then
PREV_SCORE=$(echo "$CACHE_ENTRY" | jq -r '.score // empty')
PREV_DESC=$(echo "$CACHE_ENTRY" | jq -r '.dimensions.description // empty')
PREV_CONTENT=$(echo "$CACHE_ENTRY" | jq -r '.dimensions.content // empty')
fi
fi
# Validate PREV_SCORE is numeric
if [[ -n "$PREV_SCORE" && ! "$PREV_SCORE" =~ ^[0-9]+$ ]]; then
echo "::warning::Invalid previous score for $dir: $PREV_SCORE, ignoring"
PREV_SCORE=""
fi
# Validate PREV_DESC and PREV_CONTENT are numeric
if [[ -n "$PREV_DESC" && ! "$PREV_DESC" =~ ^[0-9]+$ ]]; then
echo "::warning::Invalid previous description score for $dir: $PREV_DESC, ignoring"
PREV_DESC=""
fi
if [[ -n "$PREV_CONTENT" && ! "$PREV_CONTENT" =~ ^[0-9]+$ ]]; then
echo "::warning::Invalid previous content score for $dir: $PREV_CONTENT, ignoring"
PREV_CONTENT=""
fi
# Extract fields via jq
PASSED=$(echo "$JSON" | jq -r '.validation.overallPassed // false')
# Calculate average score from all 8 dimensions
AVG_SCORE=$(echo "$JSON" | jq -r '
def avg(obj): (obj.scores | to_entries | map(.value.score) | add) / (obj.scores | length) * 100 / 3;
(
[(.descriptionJudge.evaluation | avg(.)), (.contentJudge.evaluation | avg(.))] | add / 2
) | round
')
# Validate AVG_SCORE is numeric before arithmetic
if [[ ! "$AVG_SCORE" =~ ^[0-9]+$ ]]; then
echo "::error::Invalid average score calculated for $dir: $AVG_SCORE"
AVG_SCORE=0
fi
# Calculate diff
CHANGE=""
if [[ -n "$PREV_SCORE" ]]; then
DIFF=$((AVG_SCORE - PREV_SCORE))
if [[ $DIFF -gt 0 ]]; then
CHANGE="🔺 +${DIFF}% (was ${PREV_SCORE}%)"
elif [[ $DIFF -lt 0 ]]; then
CHANGE="🔻 ${DIFF}% (was ${PREV_SCORE}%)"
else
CHANGE="➡️ no change"
fi
fi
# Build status column
if [[ "$PASSED" == "true" ]]; then
STATUS="✅ PASSED"
else
# Extract first validation error
ERROR=$(echo "$JSON" | jq -r '
.validation.checks
| map(select(.status != "passed"))
| first
| .message // "Validation failed"
' | cut -c1-60)
STATUS="❌ FAILED — ${ERROR}"
FAILED=1
fi
DIR_DISPLAY=$(echo "$dir" | tr '|' '/')
TABLE="${TABLE}\n| \`${DIR_DISPLAY}\` | ${STATUS} | ${AVG_SCORE}% | ${CHANGE} |"
# Calculate dimension scores for cache and details
DESC_SCORE=$(echo "$JSON" | jq -r '
(.descriptionJudge.evaluation.scores | to_entries | map(.value.score) | add) * 100 / ((.descriptionJudge.evaluation.scores | length) * 3) | round
')
CONTENT_SCORE=$(echo "$JSON" | jq -r '
(.contentJudge.evaluation.scores | to_entries | map(.value.score) | add) * 100 / ((.contentJudge.evaluation.scores | length) * 3) | round
')
# Validate dimension scores
if [[ ! "$DESC_SCORE" =~ ^[0-9]+$ ]]; then
echo "::warning::Invalid description score for $dir: $DESC_SCORE, using 0"
DESC_SCORE=0
fi
if [[ ! "$CONTENT_SCORE" =~ ^[0-9]+$ ]]; then
echo "::warning::Invalid content score for $dir: $CONTENT_SCORE, using 0"
CONTENT_SCORE=0
fi
# --- Extract detailed review for collapsible section ---
DESC_EVAL=$(echo "$JSON" | jq -r '.descriptionJudge.evaluation |
" Description: " + ((.scores | to_entries | map(.value.score) | add) * 100 / ((.scores | length) * 3) | round | tostring) + "%\n" +
(.scores | to_entries | map(" \(.key): \(.value.score)/3 - \(.value.reasoning)") | join("\n")) + "\n\n" +
" Assessment: " + .overall_assessment
')
CONTENT_EVAL=$(echo "$JSON" | jq -r '.contentJudge.evaluation |
" Content: " + ((.scores | to_entries | map(.value.score) | add) * 100 / ((.scores | length) * 3) | round | tostring) + "%\n" +
(.scores | to_entries | map(" \(.key): \(.value.score)/3 - \(.value.reasoning)") | join("\n")) + "\n\n" +
" Assessment: " + .overall_assessment
')
# Extract suggestions
SUGGESTIONS=$(echo "$JSON" | jq -r '
[.descriptionJudge.evaluation.suggestions // [], .contentJudge.evaluation.suggestions // []]
| flatten
| map("- " + .)
| join("\n")
')
# Build collapsible details block
DETAILS="${DETAILS}\n\n<details>\n<summary><strong>${DIR_DISPLAY}</strong> — ${AVG_SCORE}% (${STATUS#* })</summary>\n\n"
# Show score comparison if previous exists (all three must be valid)
if [[ -n "$PREV_SCORE" && -n "$PREV_DESC" && -n "$PREV_CONTENT" ]]; then
DETAILS="${DETAILS}**Previous:** ${PREV_SCORE}% (Description: ${PREV_DESC}%, Content: ${PREV_CONTENT}%)\n"
DETAILS="${DETAILS}**Current:** ${AVG_SCORE}% (Description: ${DESC_SCORE}%, Content: ${CONTENT_SCORE}%)\n\n"
DETAILS="${DETAILS}---\n\n"
fi
DETAILS="${DETAILS}\`\`\`\n${DESC_EVAL}\n\n${CONTENT_EVAL}\n\`\`\`\n"
if [[ -n "$SUGGESTIONS" ]]; then
DETAILS="${DETAILS}\n**Suggestions:**\n\n${SUGGESTIONS}\n"
fi
DETAILS="${DETAILS}\n</details>"
# Calculate content hash
if [[ ! -f "$dir/SKILL.md" ]]; then
echo "::error::SKILL.md not found for $dir"
continue
fi
CONTENT_HASH=$(shasum -a 256 "$dir/SKILL.md" 2>&1)
if [[ $? -ne 0 ]]; then
echo "::error::Failed to calculate hash for $dir: $CONTENT_HASH"
continue
fi
CONTENT_HASH="sha256:$(echo "$CONTENT_HASH" | awk '{print $1}')"
# Build cache entry (compact to single line)
if ! CACHE_ENTRY=$(jq -nc \
--arg score "$AVG_SCORE" \
--arg passed "$PASSED" \
--arg hash "$CONTENT_HASH" \
--arg ts "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--arg desc "$DESC_SCORE" \
--arg content "$CONTENT_SCORE" \
'{
score: ($score | tonumber),
validation_passed: ($passed == "true"),
content_hash: $hash,
timestamp: $ts,
dimensions: {
description: ($desc | tonumber),
content: ($content | tonumber)
}
}'); then
echo "::error::Failed to build cache entry for $dir"
continue
fi
# Write cache entry to file (tab-separated: path<tab>json)
printf '%s\t%s\n' "$dir" "$CACHE_ENTRY" >> "$CACHE_FILE_TEMP"
done <<< "$SKILLS"
# Save cache entries file path for update step
echo "CACHE_ENTRIES_FILE=$CACHE_FILE_TEMP" >> "$GITHUB_ENV"
echo "Wrote $(wc -l < "$CACHE_FILE_TEMP") cache entries to $CACHE_FILE_TEMP"
# Build PR comment body
COMMENT_BODY=$(printf '%b' "<!-- tessl-skill-review -->\n## Tessl Skill Review Results\n\n${TABLE}\n\n---\n\n### Detailed Review\n${DETAILS}\n\n---\n_Checks: frontmatter validity, required fields, body structure, examples, line count._\n_Review score is informational — not used for pass/fail gating._")
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "comment<<${EOF_MARKER}" >> "$GITHUB_OUTPUT"
echo "$COMMENT_BODY" >> "$GITHUB_OUTPUT"
echo "${EOF_MARKER}" >> "$GITHUB_OUTPUT"
echo "$COMMENT_BODY" >> "$GITHUB_STEP_SUMMARY"
if [[ "$FAILED" -eq 1 ]]; then
echo "::error::One or more skills failed validation checks."
exit 1
fi
- name: Update review cache
if: always() && steps.detect.outputs.skills != ''
id: update-cache
run: |
CACHE_FILE=".github/.tessl/skill-review-cache.json"
mkdir -p .github/.tessl
# Load existing cache or create new structure
if [[ -f "$CACHE_FILE" ]]; then
if CACHE=$(cat "$CACHE_FILE" 2>&1); then
if ! echo "$CACHE" | jq empty 2>/dev/null; then
echo "::warning::Cache file is invalid JSON, recreating"
CACHE='{"version":"1","last_updated":"","skills":{}}'
fi
else
echo "::warning::Cache file exists but cannot be read: $CACHE"
CACHE='{"version":"1","last_updated":"","skills":{}}'
fi
else
echo "Creating new cache file..."
CACHE='{"version":"1","last_updated":"","skills":{}}'
fi
# Update timestamp
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
if ! CACHE=$(echo "$CACHE" | jq --arg ts "$TIMESTAMP" '.last_updated = $ts'); then
echo "::error::Failed to update cache timestamp"
exit 1
fi
# Merge cache updates (using TAB delimiter)
MERGED_COUNT=0
FAILED_COUNT=0
while IFS=$'\t' read -r skill_path entry_json; do
[[ -z "$skill_path" ]] && continue
if NEW_CACHE=$(echo "$CACHE" | jq --arg path "$skill_path" --argjson entry "$entry_json" \
'.skills[$path] = $entry' 2>&1); then
CACHE="$NEW_CACHE"
MERGED_COUNT=$((MERGED_COUNT + 1))
else
echo "::warning::Failed to merge cache entry for $skill_path: $NEW_CACHE"
FAILED_COUNT=$((FAILED_COUNT + 1))
continue
fi
done < "$CACHE_ENTRIES_FILE"
# Write cache file
if ! echo "$CACHE" | jq '.' > "$CACHE_FILE"; then
echo "::error::Failed to write cache file"
exit 1
fi
# Report accurate merge counts
if [[ $FAILED_COUNT -gt 0 ]]; then
echo "Cache updated with $MERGED_COUNT entries ($FAILED_COUNT failed)"
else
echo "Cache updated with $MERGED_COUNT entries"
fi
- name: Upload cache file
if: always() && steps.update-cache.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: skill-review-cache
path: .github/.tessl/skill-review-cache.json
- name: Commit optimized skills
if: >-
github.event_name == 'pull_request'
&& env.AUTO_OPTIMIZE == 'true'
run: |
if git diff --quiet HEAD; then
echo "No optimized files to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A '*.md'
if git diff --cached --quiet; then
echo "No staged changes to commit."
exit 0
fi
git commit -m "chore: auto-optimize skills [skip ci]"
git push origin HEAD:${{ github.head_ref }}
env:
AUTO_OPTIMIZE: ${{ vars.AUTO_OPTIMIZE || 'false' }}
- name: Find existing PR comment
if: github.event_name == 'pull_request' && steps.detect.outputs.skills != ''
id: find-comment
uses: peter-evans/find-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: '<!-- tessl-skill-review -->'
- name: Post or update PR comment
if: github.event_name == 'pull_request' && steps.detect.outputs.skills != ''
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: ${{ steps.review.outputs.comment }}
commit-cache:
name: Commit Cache
runs-on: ubuntu-latest
needs: review-skills
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download cache file
uses: actions/download-artifact@v4
with:
name: skill-review-cache
- name: Move cache to correct location
run: |
mkdir -p .github/.tessl
mv skill-review-cache.json .github/.tessl/skill-review-cache.json
- name: Check for cache changes
id: check
run: |
if git diff --quiet HEAD .github/.tessl/skill-review-cache.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit cache
if: steps.check.outputs.changed == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .github/.tessl/skill-review-cache.json
git commit -m "chore: update skill review cache [skip ci]"
if ! git push; then
echo "::error::Failed to push cache update to main"
exit 1
fiFile: .github/workflows/tessl-skill-review.yml
name: Tessl Skill Review
on:
pull_request:
branches: [main]
paths:
- '**/SKILL.md'
- '**/skills/**'
- '.github/workflows/tessl-skill-review.yml'
push:
branches: [main]
paths:
- '**/SKILL.md'
- '**/skills/**'
workflow_dispatch:
permissions:
contents: write # Required for cache commits
jobs:
review-skills:
name: Review Skills
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Tessl CLI
run: npm install -g @tessl/cli
- name: Detect changed skills
id: detect
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
run: |
if [[ "$EVENT_NAME" == "pull_request" ]]; then
CHANGED_SKILLS=$(git diff --name-only --diff-filter=ACMR \
"origin/${BASE_REF}"...HEAD \
-- '**/SKILL.md' '**/skills/**' | \
grep 'SKILL.md$' | \
xargs -I {} dirname {} | \
sort -u)
else
# workflow_dispatch: find all skills
CHANGED_SKILLS=$(find . -name "SKILL.md" -not -path "./node_modules/*" -not -path "./.git/*" | \
xargs -I {} dirname {} | \
sed 's|^\./||' | \
sort -u)
fi
if [[ -z "$CHANGED_SKILLS" ]]; then
echo "No skill changes detected."
echo "skills=" >> "$GITHUB_OUTPUT"
else
echo "Skills to review:"
echo "$CHANGED_SKILLS"
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "skills<<${EOF_MARKER}" >> "$GITHUB_OUTPUT"
echo "$CHANGED_SKILLS" >> "$GITHUB_OUTPUT"
echo "${EOF_MARKER}" >> "$GITHUB_OUTPUT"
fi
- name: Read review cache
if: steps.detect.outputs.skills != ''
id: cache
run: |
CACHE_FILE=".github/.tessl/skill-review-cache.json"
if [[ -f "$CACHE_FILE" ]]; then
echo "Cache file found, loading..."
if CACHE_CONTENT=$(cat "$CACHE_FILE" 2>&1); then
if echo "$CACHE_CONTENT" | jq empty 2>/dev/null; then
echo "cache_exists=true" >> "$GITHUB_OUTPUT"
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "REVIEW_CACHE<<${EOF_MARKER}" >> "$GITHUB_ENV"
echo "$CACHE_CONTENT" >> "$GITHUB_ENV"
echo "${EOF_MARKER}" >> "$GITHUB_ENV"
else
echo "::warning::Cache file is invalid JSON, ignoring"
echo "cache_exists=false" >> "$GITHUB_OUTPUT"
fi
else
echo "::warning::Cache file exists but cannot be read: $CACHE_CONTENT"
echo "cache_exists=false" >> "$GITHUB_OUTPUT"
fi
else
echo "No cache file found, will create new one"
echo "cache_exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Run skill reviews
if: steps.detect.outputs.skills != ''
id: review
env:
SKILLS: ${{ steps.detect.outputs.skills }}
TESSL_API_KEY: ${{ secrets.TESSL_API_KEY }}
AUTO_OPTIMIZE: ${{ vars.AUTO_OPTIMIZE || 'false' }}
run: |
FAILED=0
TABLE="| Skill | Status | Review Score | Change |"
TABLE="${TABLE}\n|-------|--------|--------------|--------|"
DETAILS=""
CACHE_FILE_TEMP=$(mktemp)
while IFS= read -r dir; do
[[ -z "$dir" ]] && continue
echo "::group::Reviewing $dir"
# Build review command based on auto-optimize setting
REVIEW_CMD="tessl skill review --json"
if [[ "${AUTO_OPTIMIZE:-false}" == "true" ]]; then
REVIEW_CMD="tessl skill review --json --optimize --yes"
fi
JSON_OUTPUT=$($REVIEW_CMD "$dir" 2>&1)
echo "$JSON_OUTPUT"
echo "::endgroup::"
JSON=$(echo "$JSON_OUTPUT" | sed -n '/{/,$p')
PREV_SCORE=""
PREV_DESC=""
PREV_CONTENT=""
if [[ -n "$REVIEW_CACHE" ]]; then
CACHE_ENTRY=$(echo "$REVIEW_CACHE" | jq -r --arg path "$dir" '.skills[$path] // empty')
if [[ -n "$CACHE_ENTRY" ]]; then
PREV_SCORE=$(echo "$CACHE_ENTRY" | jq -r '.score // empty')
PREV_DESC=$(echo "$CACHE_ENTRY" | jq -r '.dimensions.description // empty')
PREV_CONTENT=$(echo "$CACHE_ENTRY" | jq -r '.dimensions.content // empty')
fi
fi
if [[ -n "$PREV_SCORE" && ! "$PREV_SCORE" =~ ^[0-9]+$ ]]; then
PREV_SCORE=""
fi
if [[ -n "$PREV_DESC" && ! "$PREV_DESC" =~ ^[0-9]+$ ]]; then
PREV_DESC=""
fi
if [[ -n "$PREV_CONTENT" && ! "$PREV_CONTENT" =~ ^[0-9]+$ ]]; then
PREV_CONTENT=""
fi
PASSED=$(echo "$JSON" | jq -r '.validation.overallPassed // false')
AVG_SCORE=$(echo "$JSON" | jq -r '
def avg(obj): (obj.scores | to_entries | map(.value.score) | add) / (obj.scores | length) * 100 / 3;
(
[(.descriptionJudge.evaluation | avg(.)), (.contentJudge.evaluation | avg(.))] | add / 2
) | round
')
if [[ ! "$AVG_SCORE" =~ ^[0-9]+$ ]]; then
AVG_SCORE=0
fi
CHANGE=""
if [[ -n "$PREV_SCORE" ]]; then
DIFF=$((AVG_SCORE - PREV_SCORE))
if [[ $DIFF -gt 0 ]]; then
CHANGE="🔺 +${DIFF}% (was ${PREV_SCORE}%)"
elif [[ $DIFF -lt 0 ]]; then
CHANGE="🔻 ${DIFF}% (was ${PREV_SCORE}%)"
else
CHANGE="➡️ no change"
fi
fi
if [[ "$PASSED" == "true" ]]; then
STATUS="✅ PASSED"
else
ERROR=$(echo "$JSON" | jq -r '
.validation.checks
| map(select(.status != "passed"))
| first
| .message // "Validation failed"
' | cut -c1-60)
STATUS="❌ FAILED — ${ERROR}"
FAILED=1
fi
DIR_DISPLAY=$(echo "$dir" | tr '|' '/')
TABLE="${TABLE}\n| \`${DIR_DISPLAY}\` | ${STATUS} | ${AVG_SCORE}% | ${CHANGE} |"
DESC_SCORE=$(echo "$JSON" | jq -r '
(.descriptionJudge.evaluation.scores | to_entries | map(.value.score) | add) * 100 / ((.descriptionJudge.evaluation.scores | length) * 3) | round
')
CONTENT_SCORE=$(echo "$JSON" | jq -r '
(.contentJudge.evaluation.scores | to_entries | map(.value.score) | add) * 100 / ((.contentJudge.evaluation.scores | length) * 3) | round
')
if [[ ! "$DESC_SCORE" =~ ^[0-9]+$ ]]; then DESC_SCORE=0; fi
if [[ ! "$CONTENT_SCORE" =~ ^[0-9]+$ ]]; then CONTENT_SCORE=0; fi
DESC_EVAL=$(echo "$JSON" | jq -r '.descriptionJudge.evaluation |
" Description: " + ((.scores | to_entries | map(.value.score) | add) * 100 / ((.scores | length) * 3) | round | tostring) + "%\n" +
(.scores | to_entries | map(" \(.key): \(.value.score)/3 - \(.value.reasoning)") | join("\n")) + "\n\n" +
" Assessment: " + .overall_assessment
')
CONTENT_EVAL=$(echo "$JSON" | jq -r '.contentJudge.evaluation |
" Content: " + ((.scores | to_entries | map(.value.score) | add) * 100 / ((.scores | length) * 3) | round | tostring) + "%\n" +
(.scores | to_entries | map(" \(.key): \(.value.score)/3 - \(.value.reasoning)") | join("\n")) + "\n\n" +
" Assessment: " + .overall_assessment
')
SUGGESTIONS=$(echo "$JSON" | jq -r '
[.descriptionJudge.evaluation.suggestions // [], .contentJudge.evaluation.suggestions // []]
| flatten
| map("- " + .)
| join("\n")
')
DETAILS="${DETAILS}\n\n<details>\n<summary><strong>${DIR_DISPLAY}</strong> — ${AVG_SCORE}% (${STATUS#* })</summary>\n\n"
if [[ -n "$PREV_SCORE" && -n "$PREV_DESC" && -n "$PREV_CONTENT" ]]; then
DETAILS="${DETAILS}**Previous:** ${PREV_SCORE}% (Description: ${PREV_DESC}%, Content: ${PREV_CONTENT}%)\n"
DETAILS="${DETAILS}**Current:** ${AVG_SCORE}% (Description: ${DESC_SCORE}%, Content: ${CONTENT_SCORE}%)\n\n"
DETAILS="${DETAILS}---\n\n"
fi
DETAILS="${DETAILS}\`\`\`\n${DESC_EVAL}\n\n${CONTENT_EVAL}\n\`\`\`\n"
if [[ -n "$SUGGESTIONS" ]]; then
DETAILS="${DETAILS}\n**Suggestions:**\n\n${SUGGESTIONS}\n"
fi
DETAILS="${DETAILS}\n</details>"
if [[ ! -f "$dir/SKILL.md" ]]; then
echo "::error::SKILL.md not found for $dir"
continue
fi
CONTENT_HASH=$(shasum -a 256 "$dir/SKILL.md" 2>&1)
if [[ $? -ne 0 ]]; then
echo "::error::Failed to calculate hash for $dir: $CONTENT_HASH"
continue
fi
CONTENT_HASH="sha256:$(echo "$CONTENT_HASH" | awk '{print $1}')"
if ! CACHE_ENTRY=$(jq -nc \
--arg score "$AVG_SCORE" \
--arg passed "$PASSED" \
--arg hash "$CONTENT_HASH" \
--arg ts "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--arg desc "$DESC_SCORE" \
--arg content "$CONTENT_SCORE" \
'{
score: ($score | tonumber),
validation_passed: ($passed == "true"),
content_hash: $hash,
timestamp: $ts,
dimensions: {
description: ($desc | tonumber),
content: ($content | tonumber)
}
}'); then
echo "::error::Failed to build cache entry for $dir"
continue
fi
printf '%s\t%s\n' "$dir" "$CACHE_ENTRY" >> "$CACHE_FILE_TEMP"
done <<< "$SKILLS"
echo "CACHE_ENTRIES_FILE=$CACHE_FILE_TEMP" >> "$GITHUB_ENV"
COMMENT_BODY=$(printf '%b' "<!-- tessl-skill-review -->\n## Tessl Skill Review Results\n\n${TABLE}\n\n---\n\n### Detailed Review\n${DETAILS}\n\n---\n_Checks: frontmatter validity, required fields, body structure, examples, line count._\n_Review score is informational — not used for pass/fail gating._")
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "comment<<${EOF_MARKER}" >> "$GITHUB_OUTPUT"
echo "$COMMENT_BODY" >> "$GITHUB_OUTPUT"
echo "${EOF_MARKER}" >> "$GITHUB_OUTPUT"
echo "$COMMENT_BODY" >> "$GITHUB_STEP_SUMMARY"
if [[ "$FAILED" -eq 1 ]]; then
echo "::error::One or more skills failed validation checks."
exit 1
fi
- name: Update review cache
if: always() && steps.detect.outputs.skills != ''
id: update-cache
run: |
CACHE_FILE=".github/.tessl/skill-review-cache.json"
mkdir -p .github/.tessl
if [[ -f "$CACHE_FILE" ]]; then
if CACHE=$(cat "$CACHE_FILE" 2>&1); then
if ! echo "$CACHE" | jq empty 2>/dev/null; then
CACHE='{"version":"1","last_updated":"","skills":{}}'
fi
else
CACHE='{"version":"1","last_updated":"","skills":{}}'
fi
else
CACHE='{"version":"1","last_updated":"","skills":{}}'
fi
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
CACHE=$(echo "$CACHE" | jq --arg ts "$TIMESTAMP" '.last_updated = $ts')
while IFS=$'\t' read -r skill_path entry_json; do
[[ -z "$skill_path" ]] && continue
if NEW_CACHE=$(echo "$CACHE" | jq --arg path "$skill_path" --argjson entry "$entry_json" \
'.skills[$path] = $entry' 2>&1); then
CACHE="$NEW_CACHE"
fi
done < "$CACHE_ENTRIES_FILE"
echo "$CACHE" | jq '.' > "$CACHE_FILE"
- name: Upload cache file
if: always() && steps.update-cache.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: skill-review-cache
path: .github/.tessl/skill-review-cache.json
- name: Commit optimized skills
if: >-
github.event_name == 'pull_request'
&& env.AUTO_OPTIMIZE == 'true'
run: |
if git diff --quiet HEAD; then
echo "No optimized files to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A '*.md'
if git diff --cached --quiet; then
echo "No staged changes to commit."
exit 0
fi
git commit -m "chore: auto-optimize skills [skip ci]"
git push origin HEAD:${{ github.head_ref }}
env:
AUTO_OPTIMIZE: ${{ vars.AUTO_OPTIMIZE || 'false' }}
- name: Save PR comment artifact
if: >-
github.event_name == 'pull_request'
&& steps.detect.outputs.skills != ''
&& (steps.review.outcome == 'success' || steps.review.outputs.comment != '')
env:
COMMENT_BODY: ${{ steps.review.outputs.comment }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
mkdir -p pr-comment
echo "$PR_NUMBER" > pr-comment/pr_number
echo "$COMMENT_BODY" > pr-comment/comment.md
- name: Upload PR comment artifact
if: >-
github.event_name == 'pull_request'
&& steps.detect.outputs.skills != ''
&& (steps.review.outcome == 'success' || steps.review.outputs.comment != '')
uses: actions/upload-artifact@v4
with:
name: skill-review-comment
path: pr-comment/
commit-cache:
name: Commit Cache
runs-on: ubuntu-latest
needs: review-skills
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download cache file
uses: actions/download-artifact@v4
with:
name: skill-review-cache
- name: Move cache to correct location
run: |
mkdir -p .github/.tessl
mv skill-review-cache.json .github/.tessl/skill-review-cache.json
- name: Check for cache changes
id: check
run: |
if git diff --quiet HEAD .github/.tessl/skill-review-cache.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit cache
if: steps.check.outputs.changed == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .github/.tessl/skill-review-cache.json
git commit -m "chore: update skill review cache [skip ci]"
if ! git push; then
echo "::error::Failed to push cache update to main"
exit 1
fiFile: .github/workflows/tessl-skill-review-comment.yml
name: Post Tessl Review Comment
on:
workflow_run:
workflows: ["Tessl Skill Review"]
types: [completed]
permissions:
actions: read
pull-requests: write
jobs:
post-comment:
name: Post PR Comment
runs-on: ubuntu-latest
if: >-
github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.conclusion == 'success'
steps:
- name: Download skill-review-comment artifact
uses: actions/download-artifact@v4
with:
name: skill-review-comment
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
- name: Read PR number and comment
id: read
run: |
PR_NUMBER=$(cat pr_number)
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "comment<<${EOF_MARKER}" >> "$GITHUB_OUTPUT"
cat comment.md >> "$GITHUB_OUTPUT"
echo "${EOF_MARKER}" >> "$GITHUB_OUTPUT"
- name: Find existing comment
id: find
uses: peter-evans/find-comment@v3
with:
issue-number: ${{ steps.read.outputs.pr_number }}
comment-author: 'github-actions[bot]'
body-includes: '<!-- tessl-skill-review -->'
- name: Create or update comment
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ steps.read.outputs.pr_number }}
comment-id: ${{ steps.find.outputs.comment-id }}
edit-mode: replace
body: ${{ steps.read.outputs.comment }}When creating workflow files from these templates:
branches: [main] → branches: [{{TARGET_BRANCH}}]'**/SKILL.md' and '**/skills/**' → User's {{TRIGGER_PATHS}} (formatted as YAML array).github/.tessl/skill-review-cache.json → {{CACHE_FILE}}refs/heads/main → refs/heads/{{TARGET_BRANCH}}AUTO_OPTIMIZE repository variable → {{AUTO_OPTIMIZE}}.github/workflows/tessl-skill-review.yml.github/workflows/tessl-skill-review.yml and .github/workflows/tessl-skill-review-comment.ymlmkdir -p .github/.tessl
cat > .github/.tessl/skill-review-cache.json << 'EOF'
{
"version": "1",
"last_updated": "",
"skills": {}
}
EOF
git add .github/.tessl/skill-review-cache.json
git commit -m "feat: initialize skill review cache"
git pushTESSL_API_KEYgh workflow run "Tessl Skill Review" --ref maingit checkout -b test/verify-workflow
echo "# Test" >> path/to/SKILL.md
git add . && git commit -m "test: verify workflow"
git push -u origin test/verify-workflow
gh pr create --title "Test workflow" --body "Testing skill review setup"After merging a PR, verify:
git pull
git log --oneline -3
# Should see: "chore: update skill review cache [skip ci]"pull-requests: write permission in workflowworkflow_runcontents: write permissioncommit-cache job status (not just review-skills)pull-requests: write from main workflow permissions.github/workflows/tessl-skill-review-comment.ymlcontents: write permission (if not present)Install with Tessl CLI
npx tessl i tessl-labs/tessl-skill-review-ci@0.2.0evals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5