From 242a1035cc65460fc6ba2bf53b84013ce1f2d80d Mon Sep 17 00:00:00 2001 From: Daniel Holanda Date: Fri, 29 May 2026 16:51:01 -0700 Subject: [PATCH] Automatically update skills --- .github/workflows/import-external-skills.yml | 199 ++++++++++++++----- README.md | 6 +- scripts/import_external_skills.py | 118 ++++++++++- scripts/sources.yml | 18 +- 4 files changed, 279 insertions(+), 62 deletions(-) diff --git a/.github/workflows/import-external-skills.yml b/.github/workflows/import-external-skills.yml index 9caa0de..e700b68 100644 --- a/.github/workflows/import-external-skills.yml +++ b/.github/workflows/import-external-skills.yml @@ -1,15 +1,35 @@ name: import-external-skills -# Manually-dispatched workflow that refreshes the federated portion of the -# catalog. It reads `scripts/sources.yml`, shallow-clones each declared -# source, vendors the named skills into `skills//`, updates -# `.claude-plugin/marketplace.json`, regenerates the Cursor manifest, and -# opens a pull request with the result. Every imported skill goes through -# the same `validate` checks as in-repo skills before it lands on `main`. +# Nightly (and manually-dispatchable) workflow that refreshes the federated +# portion of the catalog. It reads `scripts/sources.yml`, shallow-clones each +# declared source, vendors the named skills into `skills//`, updates +# `.claude-plugin/marketplace.json`, regenerates the Cursor manifest, +# validates everything in-job, and pushes the result straight to `main` -- +# no pull request and no human approval required. +# +# Per-skill resilience: the import runs with `--resilient`, so a skill that +# fails per-skill validation is reverted out of the import and recorded in +# `import-report.json`. This workflow then files (or updates) a GitHub issue +# for each failed skill, while every skill that passed still lands on `main`. +# +# Direct-push prerequisites (configured outside this file): +# - The job validates the catalog itself (./scripts/check.sh plus the same +# offline lychee reference check that `validate.yml` runs), so the push +# only happens when the catalog is green. There is therefore no separate +# PR/`validate` run to gate on, and no "Allow auto-merge" setting needed. +# - `main` is a protected branch that requires pull requests, which the +# default GITHUB_TOKEN cannot bypass. The push below therefore uses an +# admin Personal Access Token stored in the `CATALOG_PUSH_TOKEN` secret +# (fine-grained PAT with Contents: read/write on this repo). The PAT's +# owner must be allowed to bypass the branch protection (admin, or added +# to the "bypass required pull requests" list). # # See the "A federated catalog" section of `README.md` for the design. on: + schedule: + # 08:00 UTC daily (~01:00 PT). Refreshes vendored skills overnight. + - cron: "0 8 * * *" workflow_dispatch: inputs: dry_run: @@ -20,7 +40,7 @@ on: permissions: contents: write - pull-requests: write + issues: write concurrency: group: import-external-skills @@ -33,71 +53,158 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + # Use the admin PAT so the later push can update the protected + # `main` branch. checkout persists these credentials for git. + token: ${{ secrets.CATALOG_PUSH_TOKEN }} - name: Set up uv uses: astral-sh/setup-uv@v7 - - name: Configure git for sparse-checkout + - name: Configure git run: | git --version git config --global init.defaultBranch main + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Dry-run import (report only) + if: ${{ inputs.dry_run == true }} + run: uv run scripts/import_external_skills.py --dry-run + + # Resilient import: skills that fail per-skill validation are reverted + # out of the working tree and recorded in import-report.json instead of + # failing the whole run. The surviving skills are what gets pushed. - name: Import skills declared in scripts/sources.yml - id: import - run: | - if [ "${{ inputs.dry_run }}" = "true" ]; then - uv run scripts/import_external_skills.py --dry-run - else - uv run scripts/import_external_skills.py - fi + if: ${{ inputs.dry_run != true }} + run: uv run scripts/import_external_skills.py --resilient --report import-report.json - name: Regenerate Cursor plugin manifest if: ${{ inputs.dry_run != true }} run: uv run scripts/generate_cursor_plugin.py + # File (or refresh) one issue per skill that failed validation, mirroring + # the issue-filing pattern in external-reference-check.yml. Done before + # the repo-wide gate so per-skill failures are reported even if a later + # check fails. + - name: File issues for skills that failed validation + if: ${{ inputs.dry_run != true }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + + if [ ! -s import-report.json ]; then + echo "No report file; nothing to file." + exit 0 + fi + + count=$(jq 'length' import-report.json) + if [ "$count" -eq 0 ]; then + echo "No skill validation failures to report." + exit 0 + fi + + # Create the label on first use; ignore if it already exists. + gh label create federated-import-failure \ + --color b60205 \ + --description "A federated skill failed validation during nightly import" \ + 2>/dev/null || true + + run_url="${SERVER_URL}/${REPO}/actions/runs/${RUN_ID}" + + jq -c '.[]' import-report.json | while read -r entry; do + skill=$(echo "$entry" | jq -r '.skill') + repo=$(echo "$entry" | jq -r '.repo') + ref=$(echo "$entry" | jq -r '.ref') + commit=$(echo "$entry" | jq -r '.commit') + output=$(echo "$entry" | jq -r '.output') + + title="Federated skill import failed: ${skill}" + + body=$(cat <Validation output + + \`\`\` + ${output} + \`\`\` + + + + Run: ${run_url} + EOF + ) + + existing=$(gh issue list \ + --state open \ + --label federated-import-failure \ + --search "in:title \"${title}\"" \ + --json number,title \ + --jq ".[] | select(.title == \"${title}\") | .number" \ + | head -n1) + + if [ -n "$existing" ]; then + echo "Updating existing issue #$existing for ${skill}" + gh issue edit "$existing" --body "$body" + gh issue comment "$existing" --body "Still failing as of $run_url" + else + echo "Filing new issue for ${skill}" + gh issue create \ + --title "$title" \ + --label federated-import-failure \ + --body "$body" + fi + done + + # Repo-wide gate: the same checks `validate.yml` enforces, run here so + # the push only happens when the catalog is green. `check.sh` covers + # per-skill + marketplace validation and the generated Cursor manifest. - name: Validate skills and manifests if: ${{ inputs.dry_run != true }} run: ./scripts/check.sh + - name: Check internal references and anchors + if: ${{ inputs.dry_run != true }} + uses: lycheeverse/lychee-action@v2 + with: + args: --config .github/lychee.toml --offline --include-fragments --no-progress "./**/*.md" + fail: true + - name: Detect changes if: ${{ inputs.dry_run != true }} id: changes run: | - if [ -z "$(git status --porcelain)" ]; then + if [ -z "$(git status --porcelain -- scripts/sources.yml skills .claude-plugin .cursor-plugin)" ]; then echo "changed=false" >> "$GITHUB_OUTPUT" - echo "No changes to import. Skipping PR." + echo "No changes to import. Nothing to push." else echo "changed=true" >> "$GITHUB_OUTPUT" git status --short fi - # Use the GitHub-maintained action to push to a dedicated branch and - # open a PR. PRs (rather than direct commits to main) keep imported - # changes reviewable and let the standard `validate` workflow gate - # them. - - name: Open pull request with imported skills + # Commit the surviving skills and push straight to main. No PR: the + # catalog was already validated above. + - name: Commit and push to main if: ${{ inputs.dry_run != true && steps.changes.outputs.changed == 'true' }} - uses: peter-evans/create-pull-request@v7 - with: - branch: bot/import-external-skills - delete-branch: true - commit-message: "chore(catalog): refresh federated skills from scripts/sources.yml" - title: "Refresh federated skills" - body: | - Automated import driven by `scripts/sources.yml`. - - See the "A federated catalog" section of `README.md` for the - design. Each vendored skill includes a `.federated.json` marker - recording the source repo, pinned ref, and resolved commit at - import time. - - Triggered by @${{ github.actor }} via the `import-external-skills` - workflow ([run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})). - labels: | - catalog - automated - add-paths: | - scripts/sources.yml - skills/** - .claude-plugin/** - .cursor-plugin/** + run: | + set -euo pipefail + git add scripts/sources.yml skills .claude-plugin .cursor-plugin + git commit -m "chore(catalog): refresh federated skills from scripts/sources.yml + + Automated nightly import driven by scripts/sources.yml. Skills that + failed per-skill validation were held back and reported as issues + labeled federated-import-failure. + + Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + git push origin "HEAD:${{ github.event.repository.default_branch }}" diff --git a/README.md b/README.md index 0a03cb9..8c8c756 100644 --- a/README.md +++ b/README.md @@ -153,11 +153,7 @@ scripts/sources.yml # Master list of external skill sources for federation In-repo skills are authored directly under `skills/`. Federated skills are declared in [`scripts/sources.yml`](scripts/sources.yml) and vendored into -`skills/` by the manually-dispatched `import-external-skills` workflow, -which opens a pull request with the imported copies. Each vendored skill -carries a `.federated.json` marker that records the upstream repo and -pinned commit, so the importer can refresh or remove it without disturbing -in-repo skills. +`skills/` by the `import-external-skills` workflow. ## Manual Installation diff --git a/scripts/import_external_skills.py b/scripts/import_external_skills.py index 10eda02..66b96f5 100644 --- a/scripts/import_external_skills.py +++ b/scripts/import_external_skills.py @@ -26,9 +26,14 @@ Usage: uv run scripts/import_external_skills.py # write changes uv run scripts/import_external_skills.py --dry-run # report only - -The companion GitHub Actions workflow `import-external-skills` calls this -script on manual dispatch and opens a pull request with the result. + uv run scripts/import_external_skills.py --resilient --report failures.json + +The companion GitHub Actions workflow `import-external-skills` runs nightly +(and on manual dispatch). It calls this script with `--resilient` so a +single skill that fails per-skill validation is reverted out of the import +and recorded in the `--report` file (the workflow files a GitHub issue for +it) instead of blocking every other skill. The surviving skills are opened +as a pull request that auto-merges once the `validate` checks pass. """ from __future__ import annotations @@ -503,6 +508,83 @@ def prune_orphans( return removed +def revert_skill(folder: str) -> None: + """Restore `skills/` to its committed state, removing new files. + + `git checkout HEAD` restores tracked files to their last-committed + content (a no-op for a brand-new skill that isn't in HEAD), and + `git clean -fd` removes any files the import added. Together they undo a + failed import whether the skill already existed or is brand new. + """ + rel = f"skills/{folder}" + # A brand-new skill has no HEAD entry, so checkout exits non-zero; that's + # expected and harmless, hence check=False. + subprocess.run( + ["git", "checkout", "HEAD", "--", rel], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + subprocess.run( + ["git", "clean", "-fd", rel], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + + +def validate_imported( + results: list[ImportResult], + report_path: Path | None, + log: list[str], +) -> tuple[list[ImportResult], list[dict]]: + """Validate each imported skill and drop the ones that fail. + + Uses the same per-skill check the `validate` workflow runs + (`validate_skills.py --skill`) so a skill kept here is one the resulting + pull request will accept. Failing skills are reverted out of the working + tree (see `revert_skill`) and recorded so the workflow can file an issue + for each. Returns `(survivors, failures)`. + """ + survivors: list[ImportResult] = [] + failures: list[dict] = [] + validator = REPO_ROOT / "scripts" / "validate_skills.py" + for result in results: + proc = subprocess.run( + ["uv", "run", str(validator), "--skill", result.folder], + cwd=REPO_ROOT, + text=True, + capture_output=True, + ) + if proc.returncode == 0: + survivors.append(result) + log.append(f"[validate] {result.folder}: OK") + continue + + output = (proc.stdout + proc.stderr).strip() + log.append( + f"[validate] {result.folder}: FAILED (reverting; an issue will " + "be filed)" + ) + revert_skill(result.folder) + failures.append( + { + "skill": result.folder, + "repo": result.source.repo, + "ref": result.source.ref, + "commit": result.commit, + "output": output, + } + ) + + if report_path is not None: + report_path.write_text( + json.dumps(failures, indent=2) + "\n", encoding="utf-8" + ) + return survivors, failures + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -516,6 +598,21 @@ def main(argv: list[str] | None = None) -> int: default=CATALOG_FILE, help=f"Path to the catalog file (default: {CATALOG_FILE}).", ) + parser.add_argument( + "--resilient", + action="store_true", + help="Validate each imported skill and revert (rather than keep) any " + "that fail, so one broken skill doesn't block the others. Implies a " + "per-skill check identical to the `validate` workflow.", + ) + parser.add_argument( + "--report", + type=Path, + default=None, + help="When used with --resilient, write a JSON array describing the " + "skills that failed validation to this path (for the workflow to " + "file issues).", + ) args = parser.parse_args(argv) sources = parse_sources(args.catalog) @@ -537,6 +634,19 @@ def main(argv: list[str] | None = None) -> int: all_results.extend(import_source(source, args.dry_run, log)) pruned = prune_orphans(declared, existing_federated, args.dry_run, log) + + failures: list[dict] = [] + if args.resilient and not args.dry_run: + # Drop skills that fail validation *before* syncing the marketplace + # so it never references a reverted skill. update_marketplace prunes + # entries whose folder no longer exists, which removes a brand-new + # failed skill's entry once revert_skill has deleted its directory. + all_results, failures = validate_imported(all_results, args.report, log) + elif args.report is not None: + # Keep the report file present (empty) even when nothing was checked, + # so the workflow can read it unconditionally. + args.report.write_text("[]\n", encoding="utf-8") + marketplace_changed = update_marketplace(all_results, args.dry_run) for line in log: @@ -544,6 +654,8 @@ def main(argv: list[str] | None = None) -> int: print("") print(f"Imported: {len(all_results)} skill(s)") + if args.resilient: + print(f"Failed validation (reverted): {len(failures)}") print(f"Removed orphans: {pruned}") print( "Marketplace: " diff --git a/scripts/sources.yml b/scripts/sources.yml index b5fbe5b..1f3c477 100644 --- a/scripts/sources.yml +++ b/scripts/sources.yml @@ -1,11 +1,11 @@ # Master list of external skill sources federated into this repository. # # Each entry under `sources:` declares a repository that owns and versions a -# set of skills. The `import-external-skills` GitHub Actions workflow (manual -# dispatch) reads this file, shallow-clones each repo at the pinned `ref`, -# and vendors the named skill folders into `skills//`. Vendored skills -# are marked with a `.federated.json` file so the importer can manage them -# without disturbing skills authored directly in this repo. +# set of skills. The `import-external-skills` GitHub Actions workflow (nightly +# cron, plus manual dispatch) reads this file, shallow-clones each repo at the +# pinned `ref`, and vendors the named skill folders into `skills//`. +# Vendored skills are marked with a `.federated.json` file so the importer can +# manage them without disturbing skills authored directly in this repo. # # Schema: # sources: @@ -18,9 +18,11 @@ # Either a bare string (folder name) or a mapping: # { name: , marketplace_description: } # -# To add or remove an imported skill, edit `skills:` here and then run the -# "Import external skills" workflow. The workflow opens a pull request with -# the resulting changes for human review. +# To add or remove an imported skill, edit `skills:` here. The next nightly +# run (or a manual dispatch) validates the changes and pushes them straight +# to `main` (no pull request). A skill that fails validation is held back and +# reported as a `federated-import-failure` issue instead of blocking the +# others. sources: - name: amd-agi-apex