Implements Tessl skill review GitHub Actions workflows in your repository through an interactive, configuration-first wizard.
Does it follow best practices?
Evaluation — 99%
↑ 1.24xAgent success when using this tile
Validation for skill structure
This version combines review and commenting into a single workflow for internal repositories where all contributors are trusted. Simpler setup with the same score diff and caching features.
.github/.tessl/skill-review-cache.json)@tessl/cliTESSL_API_KEY in GitHub SecretsCreate .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 }}
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"
# Run review with --json flag
JSON_OUTPUT=$(tessl skill review --json "$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: 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
fiCreate .github/.tessl/skill-review-cache.json:
mkdir -p .github/.tessl
cat > .github/.tessl/skill-review-cache.json << 'EOF'
{
"version": "1",
"last_updated": "2026-02-24T00:00:00Z",
"skills": {}
}
EOF
git add .github/.tessl/skill-review-cache.json
git commit -m "feat: initialize skill review cache"
git pushTESSL_API_KEYtessl-skill-review-comment.yml) - No longer neededactions: read permission - Not needed without workflow_runpull-requests: write permission - Allows direct PR commentingpermissions:
contents: write # For cache commits (same as before)
pull-requests: write # NEW: For PR commentsInstead of saving to artifact:
# OLD (two-workflow):
- name: Save PR comment artifact
run: |
mkdir -p pr-comment
echo "$COMMENT_BODY" > pr-comment/comment.mdNow posts directly:
# NEW (single-workflow):
- name: Post or update PR comment
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
body: ${{ steps.review.outputs.comment }}Finds and updates existing comments to avoid spam:
- name: Find existing PR comment
uses: peter-evans/find-comment@v3
with:
comment-author: 'github-actions[bot]'
body-includes: '<!-- tessl-skill-review -->'# After creating workflow, trigger manually
gh workflow run "Tessl Skill Review" --ref mainCheck that:
# Create a test branch
git checkout -b test/verify-workflow
# Make a small change to a skill
echo "# Test change" >> path/to/SKILL.md
# Push and create PR
git add .
git commit -m "test: verify workflow"
git push -u origin test/verify-workflow
gh pr create --title "Test workflow" --body "Testing single-workflow setup"Check that:
# Merge the test PR
gh pr merge --squash
# Wait for workflow to complete (~30 seconds)
sleep 30
# Check for cache commit
git pull
git log --oneline -3Check that:
Check:
pull-requests: write permissionFix:
# Verify permissions in workflow file
grep -A 2 "permissions:" .github/workflows/tessl-skill-review.ymlSymptom: New comment created on each push instead of updating
Cause: Can't find previous comment
Fix: Verify the marker is consistent:
# Comment must include this HTML comment
body: "<!-- tessl-skill-review -->\n## Tessl Skill Review Results..."Symptom: "refusing to allow a GitHub App to create or update workflow"
Cause: Workflow trying to modify .github/workflows/ in commit
Fix: Ensure cache commit only touches cache file:
# Should only stage cache file, not workflow
git add .github/.tessl/skill-review-cache.jsonIf you already have the two-workflow setup:
rm .github/workflows/tessl-skill-review-comment.yml
git add .github/workflows/tessl-skill-review-comment.yml
git commit -m "refactor: remove separate comment workflow"Replace .github/workflows/tessl-skill-review.yml with the single-workflow version above.
Ensure your repository settings allow Actions to create and approve pull requests:
Create a test PR to verify commenting works.
Require workflow to pass before merging:
# In repository settings:
# Branches → Add rule → Require status checks
# Select: "Review Skills"Check Actions usage regularly:
# View recent runs
gh run list --workflow="Tessl Skill Review" --limit 10Periodically review cache size:
# Check cache file size
ls -lh .github/.tessl/skill-review-cache.json
# View cache contents
cat .github/.tessl/skill-review-cache.json | jq '.skills | keys'For issues:
pull-requests: write permissionpeter-evans/create-or-update-commentInstall with Tessl CLI
npx tessl i tessl-labs/tessl-workflow-installerevals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5