Skip to content

Update External Skills #9

Update External Skills

Update External Skills #9

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