Skills Index Freshness Check #21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Skills Index Freshness Check | |
| # Belt-and-suspenders for the twice-daily build_skills_index pipeline. | |
| # If the live /docs/api/skills-index.json ever goes more than 26 hours | |
| # stale OR the file disappears entirely OR a major source has collapsed, | |
| # this workflow opens a GitHub issue so we hear about it before users do. | |
| # | |
| # Triggered every 4 hours so we catch a stuck cron within one tick. | |
| on: | |
| schedule: | |
| - cron: '0 */4 * * *' | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| issues: write | |
| jobs: | |
| check-freshness: | |
| if: github.repository == 'NousResearch/hermes-agent' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Probe live index | |
| id: probe | |
| run: | | |
| set -e | |
| URL="https://hermes-agent.nousresearch.com/docs/api/skills-index.json" | |
| echo "Probing $URL" | |
| # -L follows redirects; -f fails on HTTP errors; -s suppresses progress | |
| if ! curl -fsSL -o /tmp/skills-index.json "$URL"; then | |
| echo "status=fetch-failed" >> "$GITHUB_OUTPUT" | |
| echo "detail=Could not download $URL" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Validate + extract generated_at and per-source counts | |
| python3 <<'PY' >> "$GITHUB_OUTPUT" | |
| import json, sys | |
| from datetime import datetime, timezone | |
| try: | |
| with open("/tmp/skills-index.json") as f: | |
| data = json.load(f) | |
| except Exception as e: | |
| print(f"status=parse-failed") | |
| print(f"detail=JSON decode error: {e}") | |
| sys.exit(0) | |
| generated_at = data.get("generated_at", "") | |
| total = data.get("skill_count", 0) | |
| skills = data.get("skills", []) | |
| if not isinstance(skills, list): | |
| print("status=invalid-shape") | |
| print(f"detail=skills field is not a list (got {type(skills).__name__})") | |
| sys.exit(0) | |
| # Per-source counts | |
| from collections import Counter | |
| by_src = Counter(s.get("source", "") for s in skills) | |
| # Freshness | |
| age_hours = None | |
| try: | |
| ts = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) | |
| age_hours = (datetime.now(timezone.utc) - ts).total_seconds() / 3600 | |
| except Exception: | |
| pass | |
| # Floors — same as build_skills_index.py EXPECTED_FLOORS. | |
| floors = { | |
| "skills.sh": 100, | |
| "lobehub": 100, | |
| "clawhub": 50, | |
| "official": 50, | |
| "github": 30, | |
| "browse-sh": 50, | |
| } | |
| issues = [] | |
| if age_hours is not None and age_hours > 26: | |
| issues.append(f"Index is {age_hours:.1f}h old (limit 26h)") | |
| for src, floor in floors.items(): | |
| count = by_src.get(src, 0) | |
| if src == "skills.sh": | |
| count = by_src.get("skills.sh", 0) + by_src.get("skills-sh", 0) | |
| if count < floor: | |
| issues.append(f"{src}: {count} < {floor}") | |
| if total < 1500: | |
| issues.append(f"total skills: {total} < 1500") | |
| if issues: | |
| detail = "; ".join(issues) | |
| print("status=degraded") | |
| # GITHUB_OUTPUT doesn't allow newlines without explicit delimiter | |
| print(f"detail={detail}") | |
| else: | |
| print("status=ok") | |
| print(f"detail=Index OK — {total} skills, generated {generated_at}") | |
| by_summary = ", ".join(f"{k}={v}" for k, v in by_src.most_common(8)) | |
| print(f"summary={by_summary}") | |
| PY | |
| - name: Report status | |
| run: | | |
| echo "Probe status: ${{ steps.probe.outputs.status }}" | |
| echo "Detail: ${{ steps.probe.outputs.detail }}" | |
| if [ -n "${{ steps.probe.outputs.summary }}" ]; then | |
| echo "Summary: ${{ steps.probe.outputs.summary }}" | |
| fi | |
| - name: Open issue on degraded / failed probe | |
| if: steps.probe.outputs.status != 'ok' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| STATUS: ${{ steps.probe.outputs.status }} | |
| DETAIL: ${{ steps.probe.outputs.detail }} | |
| run: | | |
| # Find existing open issue by title prefix so we don't spam — we | |
| # append a comment instead of opening a new one each tick. | |
| TITLE_PREFIX="[skills-index-watchdog]" | |
| existing=$(gh issue list \ | |
| --repo "${{ github.repository }}" \ | |
| --state open \ | |
| --search "in:title \"$TITLE_PREFIX\"" \ | |
| --json number,title \ | |
| --jq '.[] | select(.title | startswith("'"$TITLE_PREFIX"'")) | .number' \ | |
| | head -1) | |
| BODY="Automated freshness probe failed. | |
| **Status:** \`$STATUS\` | |
| **Detail:** $DETAIL | |
| The Skills Hub at /docs/skills depends on \`/docs/api/skills-index.json\`. | |
| The unified index is rebuilt by \`.github/workflows/skills-index.yml\` (cron 6/18 UTC) | |
| and \`.github/workflows/deploy-site.yml\` (on every push affecting website/skills). | |
| If this issue keeps reopening, check the latest runs: | |
| - https://github.com/${{ github.repository }}/actions/workflows/skills-index.yml | |
| - https://github.com/${{ github.repository }}/actions/workflows/deploy-site.yml | |
| This issue was opened by \`.github/workflows/skills-index-freshness.yml\`. Close it once the underlying problem is fixed; the next probe will reopen if it's still broken." | |
| if [ -n "$existing" ]; then | |
| echo "Appending to existing issue #$existing" | |
| gh issue comment "$existing" --repo "${{ github.repository }}" --body "Probe still failing at $(date -u +%FT%TZ): \`$STATUS\` — $DETAIL" | |
| else | |
| echo "Opening new watchdog issue" | |
| gh issue create --repo "${{ github.repository }}" \ | |
| --title "$TITLE_PREFIX Skills index is stale or degraded ($STATUS)" \ | |
| --body "$BODY" | |
| fi |