Update External Skills #9
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: Update External Skills | |
| on: | |
| workflow_dispatch: {} | |
| schedule: | |
| - cron: '0 3 * * 1' # Weekly, Monday 03:00 UTC | |
| concurrency: | |
| group: update-external-skills | |
| cancel-in-progress: false | |
| jobs: | |
| update: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| - name: Fetch latest content for each external skill from its upstream source | |
| run: | | |
| set -e | |
| jq -r ' | |
| .skills | |
| | to_entries[] | |
| | select(.value.sourceType == "github" and .value.source != "firstsun-dev/skills") | |
| | .key | |
| ' skills-lock.json > /tmp/external-skill-names.txt | |
| # Group by source repo so each upstream is cloned once, not once per skill | |
| # (e.g. leonxlnx/taste-skill backs 13 of our external skills). | |
| jq -r ' | |
| .skills | |
| | to_entries[] | |
| | select(.value.sourceType == "github" and .value.source != "firstsun-dev/skills") | |
| | [.value.source, .key] | @tsv | |
| ' skills-lock.json | sort > /tmp/source-skill-pairs.tsv | |
| total=$(wc -l < /tmp/source-skill-pairs.tsv) | |
| repos=$(cut -f1 /tmp/source-skill-pairs.tsv | sort -u | wc -l) | |
| echo "Found $total external skills across $repos upstream repos" | |
| current_source="" | |
| skill_args=() | |
| flush() { | |
| if [ -n "$current_source" ]; then | |
| echo "::group::Fetching from $current_source: ${skill_args[*]}" | |
| # --agent universal pins the install target to .agents/skills/ (gitignored). | |
| # Without it, a non-interactive run with no TTY (true in CI, unlike a local | |
| # Claude Code session where the agent is auto-detected) falls back to | |
| # installing into every one of the 72 supported agent integrations — | |
| # one of which uses the untracked-by-.gitignore path "agent/skills/<name>". | |
| npx --yes skills add "$current_source" "${skill_args[@]}" --agent universal -y < /dev/null \ | |
| || echo "WARN: failed to fetch (${skill_args[*]}) from $current_source (may have been renamed/removed upstream)" | |
| echo "::endgroup::" | |
| fi | |
| } | |
| while IFS=$'\t' read -r source name; do | |
| if [ "$source" != "$current_source" ]; then | |
| flush | |
| current_source="$source" | |
| skill_args=() | |
| fi | |
| skill_args+=("-s" "$name") | |
| done < /tmp/source-skill-pairs.tsv | |
| flush | |
| - name: Overwrite external/ with freshly fetched content where it changed | |
| run: | | |
| set -e | |
| declare -A NAME_TO_DIR | |
| while IFS= read -r -d '' f; do | |
| n=$(grep -m1 '^name:' "$f" | sed -E "s/^name: *[\"']?//; s/[\"']? *\$//") | |
| NAME_TO_DIR["$n"]="$(dirname "$f")" | |
| done < <(find external -mindepth 2 -name SKILL.md -print0) | |
| shopt -s nullglob | |
| for fetched in .agents/skills/*/; do | |
| fetched_skill_md="${fetched}SKILL.md" | |
| [ -f "$fetched_skill_md" ] || continue | |
| name=$(grep -m1 '^name:' "$fetched_skill_md" | sed -E "s/^name: *[\"']?//; s/[\"']? *\$//") | |
| target_dir="${NAME_TO_DIR[$name]:-}" | |
| if [ -z "$target_dir" ]; then | |
| echo "WARN: no external/ directory maps to fetched skill '$name' ($fetched) — skipping (likely a stale lock entry)" | |
| continue | |
| fi | |
| if ! diff -rq "$fetched" "$target_dir" >/dev/null 2>&1; then | |
| echo "Updating $target_dir from upstream ($name)" | |
| rsync -a --delete "$fetched" "$target_dir/" | |
| fi | |
| done | |
| - name: Refresh skills-lock.json hashes for external/ (preserves original upstream sources) | |
| run: ./setup.sh --external --agent universal | |
| - name: Clean up local agent install artifacts (gitignored, not part of the PR) | |
| run: rm -rf .agents .claude/skills | |
| - name: Verify repo structure (skill-manager flattened design) | |
| run: ./init.sh | |
| - name: Confirm external sources in skills-lock.json still point upstream | |
| run: | | |
| # GEMINI.md mandate: external/ entries must keep pointing at their original | |
| # upstream sources, never at firstsun-dev/skills or a local path. Only check | |
| # the skills this run actually touched — custom/ legitimately uses that source. | |
| drifted="" | |
| while read -r name; do | |
| [ -z "$name" ] && continue | |
| entry=$(jq -c --arg n "$name" '.skills[$n]' skills-lock.json) | |
| source_type=$(jq -r '.sourceType' <<<"$entry") | |
| source=$(jq -r '.source' <<<"$entry") | |
| if [ "$source_type" = "local" ] || [ "$source" = "firstsun-dev/skills" ]; then | |
| drifted="$drifted $name" | |
| fi | |
| done < /tmp/external-skill-names.txt | |
| if [ -n "$drifted" ]; then | |
| echo "FAIL: these skills unexpectedly ended up local or pointed at firstsun-dev/skills:$drifted" | |
| exit 1 | |
| fi | |
| echo "OK: external sources unchanged" | |
| - name: Open PR with updated skills | |
| uses: peter-evans/create-pull-request@v6 | |
| with: | |
| token: ${{ secrets.RELEASE_TOKEN }} | |
| # Only ever stage these two — regardless of whatever else `npx skills add` | |
| # or `setup.sh` may have written to the working tree (e.g. agent install | |
| # artifacts that aren't covered by .gitignore). | |
| add-paths: | | |
| external/** | |
| skills-lock.json | |
| commit-message: "chore(external): weekly sync of external skills from upstream" | |
| branch: automated/update-external-skills | |
| delete-branch: true | |
| title: "chore(external): weekly external skills update" | |
| labels: automated,external-skills | |
| body: | | |
| Automated weekly sync of `external/` skills from their upstream sources, per `skills-lock.json`. | |
| For each external skill, this fetched the latest content from its upstream | |
| source, overwrote `external/<domain>/<skill>/` where it differed, then ran | |
| `./setup.sh --external` to refresh `skills-lock.json` hashes while preserving | |
| each skill's original upstream `source` (the same mechanism `setup.sh` already | |
| uses for manual registration). `custom/` sources are untouched. | |
| Before merging, per the [skill-manager SOP](custom/basic/skill-manager/SKILL.md): | |
| - [ ] Run `/validate-skills` on the changed skills and resolve any FAIL items | |
| - [ ] Update `SKILLS_LIST.md` if a skill's description or location changed |