CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/tessl-workflow-installer

Implements Tessl skill review GitHub Actions workflows in your repository through an interactive, configuration-first wizard.

Does it follow best practices?

Evaluation99%

1.24x

Agent success when using this tile

Validation for skill structure

Overview
Skills
Evals
Files

two-workflow.md

Tessl Skill Review CI/CD v4: Score Diff & Caching

Overview

This version adds score diff tracking and persistent caching to the skill review workflow. Contributors can see how their changes affect skill quality scores over time.

Features

✨ New in v4

  1. Score Change Indicators - Shows score improvements/decreases in PR comments

    • 🔺 Score increased (e.g., "+3% from 89%")
    • 🔻 Score decreased (e.g., "-2% from 95%")
    • ➡️ No change
    • (blank) First review, no baseline
  2. Dimension-Level Diffs - Expandable details show Previous vs Current scores

    • Description score comparison
    • Content score comparison
    • Full evaluation breakdown
  3. Persistent Cache - Scores cached in git for indefinite retention

    • Stored in .github/.tessl/skill-review-cache.json
    • Survives weeks/months between skill updates
    • Auto-committed to main after PR merges
  4. Robust Error Handling - Comprehensive validation and recovery

    • Numeric validation for all scores
    • Corrupt cache detection and recovery
    • Artifact-based data passing between jobs
    • Graceful degradation on failures

📊 Example PR Comment Output

## Tessl Skill Review Results

| Skill | Status | Review Score | Change |
|-------|--------|--------------|--------|
| `terraform/style-guide` | ✅ PASSED | 96% | 🔺 +4% (was 92%) |
| `packer/aws-ami-builder` | ✅ PASSED | 96% | ➡️ no change |
| `vault/secret-rotation` | ✅ PASSED | 88% | 🔻 -2% (was 90%) |

---

### Detailed Review

<details>
<summary><strong>terraform/style-guide</strong> — 96% (PASSED)</summary>

**Previous:** 92% (Description: 92%, Content: 92%)
**Current:**  96% (Description: 100%, Content: 92%)

---

Description: 100% specificity: 3/3 - Lists concrete actions... trigger_term_quality: 3/3 - Good coverage... completeness: 3/3 - Clear answers... distinctiveness_conflict_risk: 3/3 - Distinct niche...

Content: 92% conciseness: 3/3 - Lean and efficient... actionability: 3/3 - Concrete examples... workflow_clarity: 2/3 - Missing explicit workflow... progressive_disclosure: 3/3 - Well organized...

**Suggestions:**
- Add explicit workflow steps
- Include validation checkpoints

</details>

Setup Instructions

Prerequisites

  1. Tessl CLI - Your workflow must install @tessl/cli
  2. API Key - Store TESSL_API_KEY in GitHub Secrets
  3. Permissions - Workflow needs contents: write permission

Step 1: Create Workflow File

Create .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
              # 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.update-cache.outcome == 'success' || 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: 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
          fi

Step 2: Create Comment Posting Workflow

Create .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 }}

Step 3: Initialize Cache File

Create .github/.tessl/skill-review-cache.json:

{
  "version": "1",
  "last_updated": "2026-02-24T00:00:00Z",
  "skills": {}
}

Commit this file to your repository:

git add .github/.tessl/skill-review-cache.json
git commit -m "feat: initialize skill review cache"
git push

Step 4: Add GitHub Secret

  1. Go to your repository → Settings → Secrets and variables → Actions
  2. Click "New repository secret"
  3. Name: TESSL_API_KEY
  4. Value: Your Tessl API key
  5. Click "Add secret"

How It Works

On Pull Requests

  1. Detect Changes - Finds modified SKILL.md files
  2. Read Cache - Loads previous review scores from cache
  3. Run Reviews - Reviews each changed skill with tessl skill review --json
  4. Calculate Diffs - Compares current scores to cached scores
  5. Post Comment - Shows results with score changes and dimension diffs
  6. Update Cache - Prepares updated cache (not yet committed)

On Push to Main (After PR Merge)

  1. Review Skills - Re-runs reviews on merged changes
  2. Update Cache - Merges new scores into cache file
  3. Commit Cache - Auto-commits cache with message chore: update skill review cache [skip ci]
  4. Push Changes - Pushes cache update to main branch

Cache Structure

The cache file stores review results for future comparisons:

{
  "version": "1",
  "last_updated": "2026-02-24T10:46:53Z",
  "skills": {
    "terraform/code-generation/skills/terraform-style-guide": {
      "score": 92,
      "validation_passed": true,
      "content_hash": "sha256:abc123...",
      "timestamp": "2026-02-24T10:46:53Z",
      "dimensions": {
        "description": 92,
        "content": 92
      }
    }
  }
}

Configuration Options

Trigger Paths

Customize which file changes trigger the workflow:

on:
  pull_request:
    paths:
      - '**/SKILL.md'           # All SKILL.md files
      - '**/skills/**'          # All files in skills directories
      - 'custom/path/**'        # Add custom paths

Branch Protection

Control which branches trigger cache commits:

if: github.event_name == 'push' && github.ref == 'refs/heads/main'

Change main to your default branch name if different.

Node.js Version

Adjust the Node.js version if needed:

- name: Setup Node.js
  uses: actions/setup-node@v4
  with:
    node-version: '20'  # Change to '18' or '22' if needed

Troubleshooting

Cache Not Populating

Symptom: PR shows no score diffs, cache file stays empty

Causes:

  1. Workflow triggered by workflow_dispatch (manual) instead of push
  2. Cache entries failing validation

Solutions:

  • Make a skill change and push to main (not manual trigger)
  • Check workflow logs for "Cache updated with X entries"
  • Look for warnings about invalid scores or merge failures

Cache Not Committing

Symptom: Cache updates but doesn't get committed to main

Causes:

  1. Workflow triggered by PR (not push event)
  2. Cache file unchanged
  3. Permissions issue

Solutions:

  • Ensure workflow has contents: write permission
  • Verify commit-cache job runs (check for "SKIPPED" vs "SUCCESS")
  • Confirm changes were pushed to main branch

Score Diffs Not Showing

Symptom: PR comment shows empty Change column

Causes:

  1. First review (no baseline)
  2. Cache validation failed for previous scores
  3. Skill path doesn't match cached path

Solutions:

  • This is expected on first review after setup
  • Check for warnings about invalid previous scores
  • Verify skill paths match exactly (no trailing slashes, correct casing)

Invalid JSON Errors

Symptom: Warnings about "invalid JSON text passed to --argjson"

Causes:

  1. CACHE_ENTRY contains unescaped newlines
  2. Delimiter parsing issues

Solutions:

  • This should be fixed in v4 with compact JSON (jq -nc)
  • If persists, check that printf command uses correct escaping
  • Verify temp file isn't corrupted with cat $CACHE_FILE_TEMP

Best Practices

1. Commit Cache Regularly

  • Don't go months without merging skill updates
  • Cache commits keep baseline fresh
  • Helps identify gradual score degradation

2. Review Score Trends

  • Monitor for consistent score decreases
  • Investigate sudden large changes (±10% or more)
  • Use dimension diffs to understand what changed

3. Keep Cache in Git

  • Don't gitignore .github/.tessl/
  • Cache is meant to be version-controlled
  • Enables score tracking across team

4. Handle Skill Renames

  • Renamed skills appear as "first review" (no diff)
  • Old cache entry remains unused
  • Manual cache cleanup optional (won't affect functionality)

5. Test Changes Locally

  • Use tessl skill review --json <path> locally first
  • Verify JSON output is valid before pushing
  • Check skill passes validation before creating PR

Migration from v3

If upgrading from v3 (without score diff/caching):

1. Update Workflow File

Replace entire .github/workflows/tessl-skill-review.yml with v4 version above.

2. Update Permissions

Change from:

permissions:
  contents: read

To:

permissions:
  contents: write  # Required for cache commits

3. Initialize Cache

Create .github/.tessl/skill-review-cache.json with initial structure:

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 push

4. First Run

On first run after migration:

  • All skills show empty "Change" column (expected)
  • Cache populates after first merge to main
  • Subsequent PRs will show score diffs

Performance Considerations

Workflow Duration

  • Review step: ~2-5 seconds per skill
  • Cache operations: <1 second total
  • Artifact upload/download: ~1-2 seconds
  • Total overhead: ~5-10 seconds for cache features

Repository Size

  • Cache file size: ~200-500 bytes per skill
  • 100 skills = ~50 KB cache file
  • Minimal impact on repo size

API Rate Limits

  • Each skill review = 1 API call to Tessl
  • Standard rate limits apply
  • Cache doesn't reduce API calls (always reviews changed skills)

Advanced Usage

Skip Cache Update

To review without updating cache, add to commit message:

[skip cache]

Then modify workflow:

- name: Update review cache
  if: |
    always()
    && steps.detect.outputs.skills != ''
    && !contains(github.event.head_commit.message, '[skip cache]')

Multiple Branches

To cache scores per branch:

# In commit-cache job
run: |
  BRANCH_NAME="${GITHUB_REF#refs/heads/}"
  CACHE_FILE=".github/.tessl/skill-review-cache-${BRANCH_NAME}.json"

Custom Diff Thresholds

To highlight large score changes:

# After calculating DIFF
if [[ ${DIFF#-} -ge 10 ]]; then
  CHANGE="⚠️ ${CHANGE}"  # Highlight ±10% or more
fi

Support

For issues with:

  • Workflow: Check GitHub Actions logs, search for ::error:: or ::warning::
  • Tessl CLI: See https://docs.tessl.io/cli
  • Cache logic: Review .github/.tessl/skill-review-cache.json structure
  • Score diffs: Verify previous scores exist and are valid numbers

Changelog

v4 (2026-02-24)

  • Added score diff tracking with emoji indicators
  • Added persistent caching in git
  • Added dimension-level diff display
  • Added auto-commit of cache to main
  • Improved error handling and validation
  • Fixed cache parsing with temp file approach

v3 (2026-02-24)

  • Migrated to --json flag for robust parsing
  • Added structured output handling with jq
  • Removed fragile sed/grep parsing

v2 (Initial)

  • Basic skill review with human-readable output
  • PR comments with pass/fail status
  • Manual sed/grep parsing

Install with Tessl CLI

npx tessl i tessl-labs/tessl-workflow-installer

README.md

single-workflow.md

SKILL.md

summary.md

tessl-workflow-installer-review.md

TESTING.md

tile.json

two-workflow.md