From d7625bdcc0333002ae3916284239511cf30b4837 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 19 Apr 2026 18:01:50 +0200 Subject: [PATCH 1/3] feat(copilot-skills)!: drop lockfile, lean on gh skill frontmatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gh skill install` now records upstream provenance in each installed SKILL.md's `metadata.github-*` frontmatter, and `gh skill update` reads that to detect drift — so a sidecar skills-lock.json is redundant. setup-copilot-skills: - Replace `skills-lock` / `source` / `skills` inputs with a single `skills` input: newline list of ` [@pin]` entries, so consumers can mix upstreams freely. - Drop the jq dependency. - Pin per line via `@`; reproducible installs no longer need a lockfile. update-copilot-skills: - Replace the gh-api ref-resolution loop with `gh skill update --all --dir `; the CLI edits installed SKILL.md files in place. - New `dry-run`, `unpin`, `dir` inputs; drop `skills-lock`. - `changed` output computed from SKILL.md content hashes before/after, so git is not required in the caller's environment. Both READMEs gain a "Migrating from v1" section showing the diff. BREAKING CHANGE: `setup-copilot-skills` and `update-copilot-skills` both remove the `skills-lock` input; `setup-copilot-skills` also removes the `source` input and redefines `skills` as the sole list input. Delete any skills-lock.json and move each entry onto its own ` ` line in `setup-copilot-skills.with.skills`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 4 +- setup-copilot-skills/README.md | 92 ++++++------------- setup-copilot-skills/action.yaml | 91 +++++++++---------- update-copilot-skills/README.md | 64 ++++++-------- update-copilot-skills/action.yaml | 142 ++++++++++++------------------ 5 files changed, 151 insertions(+), 242 deletions(-) diff --git a/README.md b/README.md index c4da585..b8bfe87 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,11 @@ flowchart TD | [enable-auto-merge-on-pr](enable-auto-merge-on-pr/README.md) | Enable auto-merge on a pull request | | [login-to-ghcr](login-to-ghcr/README.md) | Login to GitHub Container Registry | | [run-dotnet-tests](run-dotnet-tests/README.md) | Test .NET solution or project with coverage | -| [setup-copilot-skills](setup-copilot-skills/README.md) | Install agent skills via `gh skill` from a manifest or inline list | +| [setup-copilot-skills](setup-copilot-skills/README.md) | Install agent skills via `gh skill` from a newline list of ` [@pin]` entries | | [setup-go-toolchain](setup-go-toolchain/README.md) | Setup Go with optional private module support | | [setup-ksail-cli](setup-ksail-cli/README.md) | Install KSail CLI via Homebrew | | [sync-github-labels](sync-github-labels/README.md) | Sync GitHub labels from a configuration file | -| [update-copilot-skills](update-copilot-skills/README.md) | Resolve and pin the latest skill refs back into `skills-lock.json` | +| [update-copilot-skills](update-copilot-skills/README.md) | Run `gh skill update --all` against installed skills and report changes | | [upsert-issue](upsert-issue/README.md) | Create, update, reopen, or close a GitHub issue by title | ## Contributing diff --git a/setup-copilot-skills/README.md b/setup-copilot-skills/README.md index 985e812..f5abbff 100644 --- a/setup-copilot-skills/README.md +++ b/setup-copilot-skills/README.md @@ -1,14 +1,14 @@ # Setup Copilot Skills -Install agent skills with the [`gh skill`](https://github.com/cli/cli) CLI — from a `skills-lock.json` manifest or an inline source/skills list. Works with any `gh skill`-compatible skills repository (e.g. [`devantler-tech/skills`](https://github.com/devantler-tech/skills)). +Install agent skills with the [`gh skill`](https://github.blog/changelog/2026-04-16-manage-agent-skills-with-github-cli/) CLI from a newline-separated list of ` [@pin]` entries. + +`gh skill install` writes upstream provenance (`metadata.github-*`) into each installed `SKILL.md`, so no lockfile is required — checked-in skills are self-describing and [`update-copilot-skills`](../update-copilot-skills/README.md) or `gh skill update --all` picks up drift natively. ## Inputs | Name | Description | Required | Default | |------|-------------|----------|---------| -| `skills-lock` | Path to a `skills-lock.json` manifest (used when `source` is empty) | ❌ | `skills-lock.json` | -| `source` | Single skills repo (e.g. `devantler-tech/skills`). When set, `skills` is required and `skills-lock` is ignored. | ❌ | - | -| `skills` | Newline- or comma-separated skill names to install from `source` | ❌ | - | +| `skills` | Newline list of ` [@pin]`. `@pin` is an optional tag/branch/SHA. `#` comments and blank lines are allowed. | ✅ | — | | `agent` | Value passed to `gh skill install --agent` | ❌ | `github-copilot` | | `scope` | Value passed to `gh skill install --scope` (`project` or `user`) | ❌ | `project` | | `gh-version` | Minimum required `gh` version (must support `gh skill`) | ❌ | `2.90.0` | @@ -18,80 +18,42 @@ Install agent skills with the [`gh skill`](https://github.com/cli/cli) CLI — f | Name | Description | |------|-------------| -| `installed-skills` | Newline-separated list of `source/skill-name` pairs that were installed | +| `installed-skills` | Newline-separated list of `source/skill-name[@pin]` entries that were installed | ## Usage -### From a `skills-lock.json` manifest (recommended) - ```yaml steps: - uses: actions/checkout@v5 - - name: Install Copilot skills - uses: devantler-tech/actions/setup-copilot-skills@main -``` - -A `skills-lock.json` has the following shape: - -```json -{ - "version": 1, - "skills": { - "git-commit": { "source": "devantler-tech/skills", "sourceType": "github" }, - "gh-cli": { "source": "devantler-tech/skills", "sourceType": "github" } - } -} -``` - -Entries may also carry an optional `ref` (release tag or branch name) and `digest` (full commit SHA). When either is present, this action installs at the pinned ref via `gh skill install --pin ` (`digest` wins when both are set), making installs reproducible: - -```json -{ - "git-commit": { - "source": "devantler-tech/skills", - "sourceType": "github", - "ref": "v0.1.1", - "digest": "6d50a758e1f2b0c4d9a3a8c0a0a0a0a0a0a0a0a0" - } -} -``` - -Use the sibling [`update-copilot-skills`](../update-copilot-skills/README.md) action to populate and bump these pins from a scheduled workflow. - -### From an inline list of skills - -```yaml -steps: - - name: Install specific skills - uses: devantler-tech/actions/setup-copilot-skills@main + - uses: devantler-tech/actions/setup-copilot-skills@v2 with: - source: devantler-tech/skills skills: | - git-commit - gh-cli - refactor + github/awesome-copilot git-commit + github/awesome-copilot gh-cli@v1.2.0 + fluxcd/agent-skills gitops-knowledge + # Pin with @ or omit to track the upstream default branch. ``` -### Customising agent and scope - -```yaml -steps: - - uses: devantler-tech/actions/setup-copilot-skills@main - with: - source: devantler-tech/skills - skills: git-commit - agent: github-copilot - # Default is `project` (writes inside the checkout, suitable for CI and - # scheduled-update workflows). Use `user` for local dev installs that - # should land in the runner's home directory. - scope: user +## Migrating from v1 + +v1 took either a `skills-lock.json` or a single `source` + `skills` list. v2 drops both inputs in favour of one flat `skills` list so consumers can mix upstreams freely: + +```diff + - uses: devantler-tech/actions/setup-copilot-skills@v1 + with: +- source: devantler-tech/skills +- skills: | +- git-commit +- gh-cli ++ uses: devantler-tech/actions/setup-copilot-skills@v2 ++ with: ++ skills: | ++ github/awesome-copilot git-commit ++ github/awesome-copilot gh-cli ``` -### Inside a scheduled update workflow - -For a batteries-included scheduled updater that opens a PR with any skill changes, use the companion reusable workflow [`devantler-tech/reusable-workflows/.github/workflows/update-copilot-skills.yaml`](https://github.com/devantler-tech/reusable-workflows/blob/main/.github/workflows/update-copilot-skills.yaml). To resolve and pin the latest skill refs back into `skills-lock.json` (so consumers get reproducible installs), use [`update-copilot-skills`](../update-copilot-skills/README.md). +Reproducible installs no longer need a lockfile — pin each line with `@` (or `@`) and `gh skill update --all` (via the sibling [`update-copilot-skills`](../update-copilot-skills/README.md) action) edits those pins in place when upstream advances. ## Requirements - `gh` **≥ 2.90.0** on the runner (with `gh skill` support). If the runner image ships an older `gh`, this action downloads the required release tarball on demand (Linux and macOS, amd64/arm64). Windows runners must pre-install `gh >= 2.90.0`. -- `jq` (pre-installed on GitHub-hosted runners) when using a `skills-lock.json`. diff --git a/setup-copilot-skills/action.yaml b/setup-copilot-skills/action.yaml index eda7cec..9bb5bd2 100644 --- a/setup-copilot-skills/action.yaml +++ b/setup-copilot-skills/action.yaml @@ -1,19 +1,13 @@ name: Setup Copilot Skills -description: Install agent skills with the gh skill CLI, from a skills-lock.json manifest or an inline source/skills list +description: Install agent skills with the gh skill CLI from a newline list of " [@pin]" entries author: devantler-tech inputs: - skills-lock: - description: Path to a skills-lock.json manifest (used when `source` is empty) - required: false - default: skills-lock.json - source: - description: Single skills repo (e.g. `devantler-tech/skills`). When set, `skills` is required and `skills-lock` is ignored. - required: false - default: "" skills: - description: Newline- or comma-separated skill names to install from `source`. Ignored when `source` is empty. - required: false - default: "" + description: | + Newline-separated list of skills to install. Each non-blank, non-comment line has the form + ` [@pin]`, where `@pin` is an optional tag, branch, or commit SHA. Blank + lines and lines starting with `#` are ignored. + required: true agent: description: Value passed to `gh skill install --agent` required: false @@ -32,7 +26,7 @@ inputs: default: ${{ github.token }} outputs: installed-skills: - description: Newline-separated list of `source/skill-name` pairs that were installed + description: Newline-separated list of `source/skill-name[@pin]` entries that were installed value: ${{ steps.install.outputs.installed-skills }} runs: using: composite @@ -97,57 +91,53 @@ runs: shell: bash env: GH_TOKEN: ${{ inputs.github-token }} - INPUT_SOURCE: ${{ inputs.source }} INPUT_SKILLS: ${{ inputs.skills }} - INPUT_SKILLS_LOCK: ${{ inputs.skills-lock }} INPUT_AGENT: ${{ inputs.agent }} INPUT_SCOPE: ${{ inputs.scope }} run: | set -euo pipefail + + if [ -z "${INPUT_SKILLS//[[:space:]]/}" ]; then + echo "::error::'skills' input is empty. Provide a newline-separated list of ' [@pin]' entries." + exit 1 + fi + installed_file=$(mktemp) + status=0 + + while IFS= read -r raw || [ -n "$raw" ]; do + line=${raw%%#*} + line=$(printf '%s' "$line" | awk '{$1=$1;print}') + [ -z "$line" ] && continue + + # shellcheck disable=SC2086 + set -- $line + if [ "$#" -lt 2 ]; then + echo "::error::Invalid skills entry: '$raw' — expected ' [@pin]'" + status=1 + continue + fi + src="$1" + spec="$2" + pin="" + name="$spec" + case "$spec" in + *@*) name=${spec%@*}; pin=${spec#*@} ;; + esac - install_one() { - local src="$1" name="$2" pin="${3:-}" if [ -n "$pin" ]; then - echo "Installing ${src}/${name} pinned to ${pin} (agent=${INPUT_AGENT}, scope=${INPUT_SCOPE})..." + echo "Installing ${src}/${name}@${pin} (agent=${INPUT_AGENT}, scope=${INPUT_SCOPE})..." gh skill install "$src" "$name" --agent "$INPUT_AGENT" --scope "$INPUT_SCOPE" --pin "$pin" else echo "Installing ${src}/${name} (agent=${INPUT_AGENT}, scope=${INPUT_SCOPE})..." gh skill install "$src" "$name" --agent "$INPUT_AGENT" --scope "$INPUT_SCOPE" fi - printf '%s/%s\n' "$src" "$name" >> "$installed_file" - } - - if [ -n "$INPUT_SOURCE" ]; then - if [ -z "$INPUT_SKILLS" ]; then - echo "::error::'skills' input is required when 'source' is set." - exit 1 - fi - printf '%s' "$INPUT_SKILLS" | tr ',' '\n' | while IFS= read -r name; do - name=$(printf '%s' "$name" | awk '{$1=$1;print}') - [ -z "$name" ] && continue - install_one "$INPUT_SOURCE" "$name" - done - else - if [ ! -f "$INPUT_SKILLS_LOCK" ]; then - echo "::error::skills-lock file not found: $INPUT_SKILLS_LOCK" - exit 1 - fi - if ! command -v jq >/dev/null 2>&1; then - echo "::error::jq is required when using the 'skills-lock' input, but it is not installed on this runner." - exit 1 + if [ -n "$pin" ]; then + printf '%s/%s@%s\n' "$src" "$name" "$pin" >> "$installed_file" + else + printf '%s/%s\n' "$src" "$name" >> "$installed_file" fi - entries=$(jq -e -c '.skills | to_entries[] | {source: .value.source, name: .key, pin: (.value.digest // .value.ref // "")}' "$INPUT_SKILLS_LOCK") || { - echo "::error::No skills found in $INPUT_SKILLS_LOCK" - exit 1 - } - while IFS= read -r entry; do - src=$(jq -r '.source' <<<"$entry") - name=$(jq -r '.name' <<<"$entry") - pin=$(jq -r '.pin' <<<"$entry") - install_one "$src" "$name" "$pin" - done <<<"$entries" - fi + done <<<"$INPUT_SKILLS" { echo "installed-skills<> "$GITHUB_OUTPUT" rm -f "$installed_file" + exit "$status" diff --git a/update-copilot-skills/README.md b/update-copilot-skills/README.md index a81cb39..774363c 100644 --- a/update-copilot-skills/README.md +++ b/update-copilot-skills/README.md @@ -1,21 +1,16 @@ # Update Copilot Skills -Resolve the latest ref + commit SHA for each skill in `skills-lock.json` and pin them back into the lockfile so subsequent installs are reproducible. Pairs with [`setup-copilot-skills`](../setup-copilot-skills/README.md). +Run [`gh skill update --all`](https://github.blog/changelog/2026-04-16-manage-agent-skills-with-github-cli/) against installed skills and report any changes. Pairs with [`setup-copilot-skills`](../setup-copilot-skills/README.md). -For each skill the action: - -1. Calls `gh api repos//releases/latest` for the newest release tag. -2. Falls back to the repository's default branch when no release exists. -3. Resolves the commit SHA for that ref via `gh api repos//commits/`. -4. Writes the resolved `ref` and `digest` (full commit SHA) back into the lockfile entry. - -Run it from a scheduled workflow — the diff on `skills-lock.json` is the renovate-style "version bump" PR. +The `github-*` frontmatter that `gh skill install` injects into each `SKILL.md` (github-repo, github-path, github-ref, github-tree-sha) is the source of truth — this action asks the CLI to refresh those files against their upstreams, then reports whether any of them changed. No lockfile. ## Inputs | Name | Description | Required | Default | |------|-------------|----------|---------| -| `skills-lock` | Path to the `skills-lock.json` manifest to update | ❌ | `skills-lock.json` | +| `dir` | Directory to scan for installed skills (passed to `gh skill update --dir`) | ❌ | `.` | +| `dry-run` | When `true`, pass `--dry-run` (report without modifying files) | ❌ | `false` | +| `unpin` | When `true`, pass `--unpin` (clear pinned versions and include pinned skills) | ❌ | `false` | | `gh-version` | Minimum required `gh` version (must support `gh skill`) | ❌ | `2.90.0` | | `github-token` | GitHub token exposed to `gh` as `GH_TOKEN` | ❌ | `${{ github.token }}` | @@ -23,12 +18,12 @@ Run it from a scheduled workflow — the diff on `skills-lock.json` is the renov | Name | Description | |------|-------------| -| `changed` | `true` when the lockfile was modified, `false` otherwise | -| `updated-skills` | Newline-separated list of `name old-digest -> new-digest` lines for skills whose pins changed | +| `changed` | `true` when at least one `SKILL.md` was modified, `false` otherwise | +| `updated-skills` | Cleaned stdout from `gh skill update --all` (blank when nothing changed) | ## Usage -### Scheduled lockfile bump +### Scheduled update PR ```yaml name: 🔄 Update Copilot Skills @@ -51,47 +46,42 @@ jobs: with: persist-credentials: true - - id: bump - uses: devantler-tech/actions/update-copilot-skills@main + - id: update + uses: devantler-tech/actions/update-copilot-skills@v2 + with: + dir: .agents/skills - - if: steps.bump.outputs.changed == 'true' + - if: steps.update.outputs.changed == 'true' uses: peter-evans/create-pull-request@v8 with: commit-message: "chore(deps): update copilot skills" title: "chore(deps): update copilot skills" body: | - Automated update of Copilot skills. - ``` - ${{ steps.bump.outputs.updated-skills }} + ${{ steps.update.outputs.updated-skills }} ``` branch: deps/copilot-skills-update labels: dependencies,automation ``` -For a batteries-included version of the above, prefer the reusable workflow [`devantler-tech/reusable-workflows/.github/workflows/update-copilot-skills.yaml`](https://github.com/devantler-tech/reusable-workflows/blob/main/.github/workflows/update-copilot-skills.yaml). +For a batteries-included version of the above, use the reusable workflow [`devantler-tech/reusable-workflows/.github/workflows/update-copilot-skills.yaml`](https://github.com/devantler-tech/reusable-workflows/blob/main/.github/workflows/update-copilot-skills.yaml). -## Lockfile shape +## Migrating from v1 -After the first run, entries gain `ref` and `digest`: +v1 resolved refs against a `skills-lock.json` manifest and wrote pins back into it. v2 delegates to `gh skill update --all` directly, operating on the installed `SKILL.md` files themselves: -```json -{ - "version": 1, - "skills": { - "git-commit": { - "source": "devantler-tech/skills", - "sourceType": "github", - "ref": "v0.1.1", - "digest": "6d50a758e1f2b0c4d9a3a8c0a0a0a0a0a0a0a0a0" - } - } -} +```diff + - id: update +- uses: devantler-tech/actions/update-copilot-skills@v1 +- with: +- skills-lock: skills-lock.json ++ uses: devantler-tech/actions/update-copilot-skills@v2 ++ with: ++ dir: .agents/skills ``` -`setup-copilot-skills` honors `digest` (preferred) or `ref` via `gh skill install --pin`, so once an entry is pinned, every subsequent CI install is reproducible. +Delete any `skills-lock.json` in your repo — the upstream source/ref/tree SHA now lives in each skill's frontmatter. ## Requirements -- `gh` **≥ 2.90.0** on the runner. Same bootstrap behavior as `setup-copilot-skills`: if the runner image ships an older `gh`, the action downloads the required release tarball on demand (Linux and macOS, amd64/arm64). Windows runners must pre-install `gh >= 2.90.0`. -- `jq` (pre-installed on GitHub-hosted runners). +- `gh` **≥ 2.90.0** on the runner. Same bootstrap behaviour as `setup-copilot-skills`: if the runner image ships an older `gh`, the action downloads the required release tarball on demand (Linux and macOS, amd64/arm64). Windows runners must pre-install `gh >= 2.90.0`. diff --git a/update-copilot-skills/action.yaml b/update-copilot-skills/action.yaml index 2513a1d..aa7358a 100644 --- a/update-copilot-skills/action.yaml +++ b/update-copilot-skills/action.yaml @@ -1,11 +1,19 @@ name: Update Copilot Skills -description: Resolve the latest ref + commit SHA for each skill in skills-lock.json and pin them back into the lockfile +description: Run `gh skill update --all` against installed skills and report any changes author: devantler-tech inputs: - skills-lock: - description: Path to the skills-lock.json manifest to update + dir: + description: Directory to scan for installed skills (passed to `gh skill update --dir`) required: false - default: skills-lock.json + default: "." + dry-run: + description: When `true`, pass `--dry-run` to `gh skill update` (report without modifying files) + required: false + default: "false" + unpin: + description: When `true`, pass `--unpin` to `gh skill update` (clear pins and include pinned skills) + required: false + default: "false" gh-version: description: Minimum required `gh` version (must support `gh skill`) required: false @@ -16,11 +24,11 @@ inputs: default: ${{ github.token }} outputs: changed: - description: "`true` when the lockfile was modified, `false` otherwise" - value: ${{ steps.resolve.outputs.changed }} + description: "`true` when at least one SKILL.md was modified, `false` otherwise" + value: ${{ steps.update.outputs.changed }} updated-skills: - description: Newline-separated list of `name old-digest -> new-digest` lines for skills whose pins changed - value: ${{ steps.resolve.outputs.updated-skills }} + description: Raw stdout from `gh skill update --all` (blank when nothing changed) + value: ${{ steps.update.outputs.updated-skills }} runs: using: composite steps: @@ -81,105 +89,63 @@ runs: exit 1 fi - - name: 🔄 Resolve and pin latest skill refs - id: resolve + - name: 🔄 Update skills + id: update shell: bash env: GH_TOKEN: ${{ inputs.github-token }} - INPUT_SKILLS_LOCK: ${{ inputs.skills-lock }} + INPUT_DIR: ${{ inputs.dir }} + INPUT_DRY_RUN: ${{ inputs.dry-run }} + INPUT_UNPIN: ${{ inputs.unpin }} run: | set -euo pipefail - if [ ! -f "$INPUT_SKILLS_LOCK" ]; then - echo "::error::skills-lock file not found: $INPUT_SKILLS_LOCK" - exit 1 - fi - if ! command -v jq >/dev/null 2>&1; then - echo "::error::jq is required by update-copilot-skills but is not installed on this runner." + if [ ! -d "$INPUT_DIR" ]; then + echo "::error::Directory not found: $INPUT_DIR" exit 1 fi - # Resolve latest tag (release) or default branch HEAD for a source repo. - # Echoes " " on stdout. - resolve_ref() { - local source="$1" - local ref sha release_json release_error_file release_error_message - release_error_file=$(mktemp) - if release_json=$(gh api "repos/${source}/releases/latest" 2>"$release_error_file"); then - ref=$(jq -r '.tag_name' <<<"$release_json") - else - release_error_message=$(tr '\n' ' ' <"$release_error_file") - if grep -qiE '404|Not Found' "$release_error_file"; then - ref=$(gh api "repos/${source}" --jq '.default_branch') - else - rm -f "$release_error_file" - echo "::error::Failed to resolve latest release for ${source}: ${release_error_message}" - return 1 - fi - fi - rm -f "$release_error_file" - if [ -z "$ref" ] || [ "$ref" = "null" ]; then - echo "::error::Could not resolve a ref for ${source}" - return 1 - fi - sha=$(gh api "repos/${source}/commits/${ref}" --jq '.sha') - if [ -z "$sha" ] || [ "$sha" = "null" ]; then - echo "::error::Could not resolve a commit SHA for ${source}@${ref}" - return 1 - fi - printf '%s %s\n' "$ref" "$sha" - } + flags=(--all --dir "$INPUT_DIR") + case "$INPUT_DRY_RUN" in + true|1|yes) flags+=(--dry-run) ;; + esac + case "$INPUT_UNPIN" in + true|1|yes) flags+=(--unpin) ;; + esac - names=$(jq -e -r '.skills | keys[]' "$INPUT_SKILLS_LOCK") || { - echo "::error::No skills found in $INPUT_SKILLS_LOCK" - exit 1 - } + # Snapshot SKILL.md contents before running so we can detect modifications + # without relying on git being present in the caller's environment. + before=$(mktemp) + find "$INPUT_DIR" -type f -name SKILL.md -print0 2>/dev/null \ + | sort -z \ + | xargs -0 shasum -a 256 2>/dev/null >"$before" || true - updated_file=$(mktemp) - lock_dir=$(dirname -- "$INPUT_SKILLS_LOCK") - tmp_lock=$(mktemp "${lock_dir}/.skills-lock.XXXXXXXX") - cp "$INPUT_SKILLS_LOCK" "$tmp_lock" + out=$(mktemp) + if ! gh skill update "${flags[@]}" 2>&1 | tee "$out"; then + echo "::error::'gh skill update ${flags[*]}' failed." + rm -f "$before" "$out" + exit 1 + fi - while IFS= read -r name; do - [ -z "$name" ] && continue - source=$(jq -r --arg n "$name" '.skills[$n].source' "$tmp_lock") - if [ -z "$source" ] || [ "$source" = "null" ]; then - echo "::error::Skill '$name' is missing 'source' in $INPUT_SKILLS_LOCK" - exit 1 - fi - old_digest=$(jq -r --arg n "$name" '.skills[$n].digest // ""' "$tmp_lock") - echo "Resolving latest ref for ${source} (skill: ${name})..." - if ! resolved=$(resolve_ref "$source"); then - echo "::error::Failed to resolve latest ref for '${source}' (skill: '${name}')" - exit 1 - fi - read -r new_ref new_digest <<<"$resolved" - if [ -z "$new_ref" ] || [ -z "$new_digest" ]; then - echo "::error::Resolved ref for '${source}' (skill: '${name}') was incomplete: '${resolved}'" - exit 1 - fi - echo " -> ref=${new_ref} digest=${new_digest}" - jq --arg n "$name" --arg ref "$new_ref" --arg digest "$new_digest" \ - '.skills[$n].ref = $ref | .skills[$n].digest = $digest' \ - "$tmp_lock" > "${tmp_lock}.next" - mv "${tmp_lock}.next" "$tmp_lock" - if [ "$old_digest" != "$new_digest" ]; then - printf '%s %s -> %s\n' "$name" "${old_digest:-}" "$new_digest" >> "$updated_file" - fi - done <<<"$names" + after=$(mktemp) + find "$INPUT_DIR" -type f -name SKILL.md -print0 2>/dev/null \ + | sort -z \ + | xargs -0 shasum -a 256 2>/dev/null >"$after" || true - if cmp -s "$INPUT_SKILLS_LOCK" "$tmp_lock"; then + if cmp -s "$before" "$after"; then echo "changed=false" >> "$GITHUB_OUTPUT" - echo "No changes to $INPUT_SKILLS_LOCK" else - mv "$tmp_lock" "$INPUT_SKILLS_LOCK" echo "changed=true" >> "$GITHUB_OUTPUT" - echo "Updated $INPUT_SKILLS_LOCK" fi + # Strip spinner carriage-returns + ANSI escape sequences before exposing stdout as an output. + cleaned=$(sed -e 's/\r/\n/g' -e 's/\x1B\[[0-9;]*[a-zA-Z]//g' "$out" \ + | grep -vE '^Checking .* installed skill' \ + | sed '/^$/d') { echo "updated-skills<> "$GITHUB_OUTPUT" - rm -f "$updated_file" "$tmp_lock" "${tmp_lock}.next" 2>/dev/null || true + + rm -f "$before" "$after" "$out" From 210e061b2e2a23bca64896482eb1494b9fd1e156 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 19 Apr 2026 18:16:14 +0200 Subject: [PATCH 2/3] fix(copilot-skills): rewrite tests for v2 API + tighten error handling - Rewrite `test-setup-copilot-skills.yaml` to exercise the new flat `skills: [@pin]` input (inline, pinned, missing-input, malformed-line cases). - Rewrite `test-update-copilot-skills.yaml` to seed a pinned install and assert `gh skill update --all` is a no-op, that `--dry-run` does not mutate SKILL.md, and that a missing `dir` fails. - `setup-copilot-skills/action.yaml`: require exactly two whitespace-separated tokens per line (reject silent extra tokens). - `update-copilot-skills/action.yaml`: fail fast when `shasum` is missing and drop the `|| true` swallow so hashing errors surface. Addresses review threads on PR #95. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/test-setup-copilot-skills.yaml | 141 ++++++++---------- .../workflows/test-update-copilot-skills.yaml | 109 ++++++-------- setup-copilot-skills/action.yaml | 4 +- update-copilot-skills/action.yaml | 12 +- 4 files changed, 119 insertions(+), 147 deletions(-) diff --git a/.github/workflows/test-setup-copilot-skills.yaml b/.github/workflows/test-setup-copilot-skills.yaml index 2564290..5e2c540 100644 --- a/.github/workflows/test-setup-copilot-skills.yaml +++ b/.github/workflows/test-setup-copilot-skills.yaml @@ -9,7 +9,7 @@ permissions: contents: read jobs: - test-from-lock: + test-inline: runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -26,33 +26,40 @@ jobs: with: persist-credentials: false - - name: 🧰 Create skills-lock.json fixture - shell: bash - run: | - cat > skills-lock.json <<'JSON' - { - "version": 1, - "skills": { - "git-commit": { "source": "devantler-tech/skills", "sourceType": "github" } - } - } - JSON - - - name: 🧪 Run setup-copilot-skills (manifest) + - name: 🧪 Run setup-copilot-skills id: setup uses: ./setup-copilot-skills + with: + skills: | + github/awesome-copilot git-commit + github/awesome-copilot gh-cli - - name: ✅ Verify installed-skills output + - name: ✅ Verify both skills installed shell: bash env: INSTALLED: ${{ steps.setup.outputs.installed-skills }} run: | - if [[ "$INSTALLED" != *"devantler-tech/skills/git-commit"* ]]; then - echo "::error::Expected 'devantler-tech/skills/git-commit' in installed-skills output, got: $INSTALLED" - exit 1 - fi + set -euo pipefail + for expected in "github/awesome-copilot/git-commit" "github/awesome-copilot/gh-cli"; do + if [[ "$INSTALLED" != *"$expected"* ]]; then + echo "::error::Expected '$expected' in installed-skills output, got: $INSTALLED" + exit 1 + fi + done + for skill in git-commit gh-cli; do + if [[ ! -f ".agents/skills/${skill}/SKILL.md" ]]; then + echo "::error::Expected .agents/skills/${skill}/SKILL.md to exist" + find .agents/skills -maxdepth 3 -type f || true + exit 1 + fi + if ! grep -q '^ github-repo: https://github.com/github/awesome-copilot$' ".agents/skills/${skill}/SKILL.md"; then + echo "::error::${skill} SKILL.md does not carry expected github-repo metadata" + cat ".agents/skills/${skill}/SKILL.md" + exit 1 + fi + done - test-inline-source: + test-pinned: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) @@ -65,26 +72,34 @@ jobs: with: persist-credentials: false - - name: 🧪 Run setup-copilot-skills (inline) + - name: 🧪 Run setup-copilot-skills with @pin id: setup uses: ./setup-copilot-skills with: - source: devantler-tech/skills skills: | - git-commit - gh-cli + github/awesome-copilot git-commit@8fbf6c4a798df51d1d1d8fd37a1aa7e94203109c - - name: ✅ Verify both skills installed + - name: ✅ Verify pinned commit ended up in SKILL.md metadata shell: bash env: INSTALLED: ${{ steps.setup.outputs.installed-skills }} run: | - for expected in "devantler-tech/skills/git-commit" "devantler-tech/skills/gh-cli"; do - if [[ "$INSTALLED" != *"$expected"* ]]; then - echo "::error::Expected '$expected' in installed-skills output, got: $INSTALLED" - exit 1 - fi - done + set -euo pipefail + if [[ "$INSTALLED" != *"github/awesome-copilot/git-commit@8fbf6c4a798df51d1d1d8fd37a1aa7e94203109c"* ]]; then + echo "::error::Expected pinned entry in installed-skills output, got: $INSTALLED" + exit 1 + fi + skill_md=".agents/skills/git-commit/SKILL.md" + if [[ ! -f "$skill_md" ]]; then + echo "::error::Could not locate installed git-commit SKILL.md at $skill_md" + find .agents/skills -maxdepth 3 -type f || true + exit 1 + fi + if ! grep -q '8fbf6c4a798df51d1d1d8fd37a1aa7e94203109c' "$skill_md"; then + echo "::error::Installed skill metadata does not reference the pinned commit:" + cat "$skill_md" + exit 1 + fi test-missing-skills-input: runs-on: ubuntu-latest @@ -99,12 +114,12 @@ jobs: with: persist-credentials: false - - name: 🧪 Run setup-copilot-skills with source but no skills (expected to fail) + - name: 🧪 Run setup-copilot-skills with blank skills input (expected to fail) id: setup continue-on-error: true uses: ./setup-copilot-skills with: - source: devantler-tech/skills + skills: " \n# only a comment\n" - name: ✅ Verify step failed as expected shell: bash @@ -112,11 +127,11 @@ jobs: OUTCOME: ${{ steps.setup.outcome }} run: | if [[ "$OUTCOME" != "failure" ]]; then - echo "::error::Expected the action to fail when 'source' is set but 'skills' is empty, got: $OUTCOME" + echo "::error::Expected the action to fail when 'skills' has no actionable entries, got: $OUTCOME" exit 1 fi - test-from-lock-pinned: + test-malformed-line: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) @@ -129,56 +144,27 @@ jobs: with: persist-credentials: false - - name: 🧰 Create skills-lock.json fixture (pinned to a historical commit) - shell: bash - run: | - cat > skills-lock.json <<'JSON' - { - "version": 1, - "skills": { - "git-commit": { - "source": "devantler-tech/skills", - "sourceType": "github", - "ref": "v0.1.1", - "digest": "6d50a7587e0ff372277dc4a33ccb8b8ea2ff7470" - } - } - } - JSON - - - name: 🧪 Run setup-copilot-skills (manifest with pin) + - name: 🧪 Run setup-copilot-skills with an extra token (expected to fail) id: setup + continue-on-error: true uses: ./setup-copilot-skills with: - scope: project + skills: | + github/awesome-copilot git-commit extra-token - - name: ✅ Verify pinned skill installed at the resolved commit + - name: ✅ Verify step failed as expected shell: bash env: - INSTALLED: ${{ steps.setup.outputs.installed-skills }} + OUTCOME: ${{ steps.setup.outcome }} run: | - set -euo pipefail - if [[ "$INSTALLED" != *"devantler-tech/skills/git-commit"* ]]; then - echo "::error::Expected 'devantler-tech/skills/git-commit' in installed-skills output, got: $INSTALLED" - exit 1 - fi - # gh skill writes source-tracking metadata into SKILL.md frontmatter. - # Confirm the pinned commit SHA is present so we know --pin was honored. - skill_md=$(find .agents/skills -name SKILL.md -path '*git-commit*' | head -1) - if [[ -z "$skill_md" ]]; then - echo "::error::Could not locate installed git-commit SKILL.md" - find .agents/skills -maxdepth 4 -type f - exit 1 - fi - if ! grep -q '6d50a7587e0ff372277dc4a33ccb8b8ea2ff7470' "$skill_md"; then - echo "::error::Installed skill metadata does not reference the pinned commit:" - cat "$skill_md" + if [[ "$OUTCOME" != "failure" ]]; then + echo "::error::Expected the action to fail when a line has more than two tokens, got: $OUTCOME" exit 1 fi status-check: if: ${{ always() && !cancelled() }} - needs: [test-from-lock, test-from-lock-pinned, test-inline-source, test-missing-skills-input] + needs: [test-inline, test-pinned, test-missing-skills-input, test-malformed-line] runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) @@ -188,13 +174,12 @@ jobs: - name: Check status env: - RESULT_LOCK: ${{ needs.test-from-lock.result }} - RESULT_LOCK_PINNED: ${{ needs.test-from-lock-pinned.result }} - RESULT_INLINE: ${{ needs.test-inline-source.result }} + RESULT_INLINE: ${{ needs.test-inline.result }} + RESULT_PINNED: ${{ needs.test-pinned.result }} RESULT_MISSING: ${{ needs.test-missing-skills-input.result }} + RESULT_MALFORMED: ${{ needs.test-malformed-line.result }} run: | - results=("$RESULT_LOCK" "$RESULT_LOCK_PINNED" "$RESULT_INLINE" "$RESULT_MISSING") - for result in "${results[@]}"; do + for result in "$RESULT_INLINE" "$RESULT_PINNED" "$RESULT_MISSING" "$RESULT_MALFORMED"; do if [[ "$result" == "failure" ]]; then exit 1 fi diff --git a/.github/workflows/test-update-copilot-skills.yaml b/.github/workflows/test-update-copilot-skills.yaml index 81446aa..cc11db7 100644 --- a/.github/workflows/test-update-copilot-skills.yaml +++ b/.github/workflows/test-update-copilot-skills.yaml @@ -9,7 +9,7 @@ permissions: contents: read jobs: - test-update-from-lock: + test-update-noop: runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -26,49 +26,37 @@ jobs: with: persist-credentials: false - - name: 🧰 Create skills-lock.json fixture (no pin) - shell: bash - run: | - cat > skills-lock.json <<'JSON' - { - "version": 1, - "skills": { - "git-commit": { "source": "devantler-tech/skills", "sourceType": "github" } - } - } - JSON - - - name: 🔄 Run update-copilot-skills + - name: 📦 Install a skill pinned to a historical commit + uses: ./setup-copilot-skills + with: + skills: | + github/awesome-copilot git-commit@8fbf6c4a798df51d1d1d8fd37a1aa7e94203109c + + - name: 🔄 Run update-copilot-skills (should be a no-op against a pinned skill) id: update uses: ./update-copilot-skills + with: + dir: .agents/skills - - name: ✅ Verify lockfile gained ref + digest + - name: ✅ Verify no-op shell: bash env: CHANGED: ${{ steps.update.outputs.changed }} UPDATED: ${{ steps.update.outputs.updated-skills }} run: | set -euo pipefail - if [[ "$CHANGED" != "true" ]]; then - echo "::error::Expected changed=true on first run, got: $CHANGED" - exit 1 - fi - ref=$(jq -r '.skills["git-commit"].ref' skills-lock.json) - digest=$(jq -r '.skills["git-commit"].digest' skills-lock.json) - if [[ -z "$ref" || "$ref" == "null" ]]; then - echo "::error::ref was not written to lockfile" - exit 1 - fi - if [[ ! "$digest" =~ ^[0-9a-f]{40}$ ]]; then - echo "::error::digest is not a 40-char SHA: $digest" + if [[ "$CHANGED" != "false" ]]; then + echo "::error::Expected changed=false when all skills are pinned, got: $CHANGED" + echo "updated-skills output: $UPDATED" exit 1 fi - if [[ "$UPDATED" != *"git-commit"* ]]; then - echo "::error::updated-skills output did not mention git-commit: $UPDATED" + if ! grep -q '8fbf6c4a798df51d1d1d8fd37a1aa7e94203109c' .agents/skills/git-commit/SKILL.md; then + echo "::error::Pinned commit SHA was removed from SKILL.md metadata" + cat .agents/skills/git-commit/SKILL.md exit 1 fi - test-update-noop: + test-update-dry-run: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) @@ -81,42 +69,38 @@ jobs: with: persist-credentials: false - - name: 🧰 Create skills-lock.json fixture (no pin) + - name: 📦 Install a skill pinned to a historical commit + uses: ./setup-copilot-skills + with: + skills: | + github/awesome-copilot git-commit@8fbf6c4a798df51d1d1d8fd37a1aa7e94203109c + + - name: 🔢 Snapshot SKILL.md hash before + id: before shell: bash - run: | - cat > skills-lock.json <<'JSON' - { - "version": 1, - "skills": { - "git-commit": { "source": "devantler-tech/skills", "sourceType": "github" } - } - } - JSON - - - name: 🔄 First run (resolves and writes pins) - uses: ./update-copilot-skills + run: echo "sha=$(shasum -a 256 .agents/skills/git-commit/SKILL.md | awk '{print $1}')" >> "$GITHUB_OUTPUT" - - name: 🔄 Second run (should be a no-op) - id: second + - name: 🔄 Run update-copilot-skills with --unpin --dry-run + id: update uses: ./update-copilot-skills + with: + dir: .agents/skills + unpin: "true" + dry-run: "true" - - name: ✅ Verify second run is a no-op + - name: ✅ Verify SKILL.md was not modified on disk shell: bash env: - CHANGED: ${{ steps.second.outputs.changed }} - UPDATED: ${{ steps.second.outputs.updated-skills }} + BEFORE: ${{ steps.before.outputs.sha }} run: | set -euo pipefail - if [[ "$CHANGED" != "false" ]]; then - echo "::error::Expected changed=false on second run, got: $CHANGED" - exit 1 - fi - if [[ -n "${UPDATED//[[:space:]]/}" ]]; then - echo "::error::Expected empty updated-skills on no-op run, got: $UPDATED" + after=$(shasum -a 256 .agents/skills/git-commit/SKILL.md | awk '{print $1}') + if [[ "$BEFORE" != "$after" ]]; then + echo "::error::SKILL.md was modified despite --dry-run (before=$BEFORE after=$after)" exit 1 fi - test-missing-lockfile: + test-missing-dir: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) @@ -129,12 +113,12 @@ jobs: with: persist-credentials: false - - name: 🔄 Run update-copilot-skills against missing file (expected to fail) + - name: 🔄 Run update-copilot-skills against a missing dir (expected to fail) id: update continue-on-error: true uses: ./update-copilot-skills with: - skills-lock: does-not-exist.json + dir: does-not-exist - name: ✅ Verify step failed as expected shell: bash @@ -142,13 +126,13 @@ jobs: OUTCOME: ${{ steps.update.outcome }} run: | if [[ "$OUTCOME" != "failure" ]]; then - echo "::error::Expected the action to fail on missing lockfile, got: $OUTCOME" + echo "::error::Expected the action to fail on a missing directory, got: $OUTCOME" exit 1 fi status-check: if: ${{ always() && !cancelled() }} - needs: [test-update-from-lock, test-update-noop, test-missing-lockfile] + needs: [test-update-noop, test-update-dry-run, test-missing-dir] runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) @@ -158,12 +142,11 @@ jobs: - name: Check status env: - RESULT_FROM_LOCK: ${{ needs.test-update-from-lock.result }} RESULT_NOOP: ${{ needs.test-update-noop.result }} - RESULT_MISSING: ${{ needs.test-missing-lockfile.result }} + RESULT_DRY: ${{ needs.test-update-dry-run.result }} + RESULT_MISSING: ${{ needs.test-missing-dir.result }} run: | - results=("$RESULT_FROM_LOCK" "$RESULT_NOOP" "$RESULT_MISSING") - for result in "${results[@]}"; do + for result in "$RESULT_NOOP" "$RESULT_DRY" "$RESULT_MISSING"; do if [[ "$result" == "failure" ]]; then exit 1 fi diff --git a/setup-copilot-skills/action.yaml b/setup-copilot-skills/action.yaml index 9bb5bd2..c187ef5 100644 --- a/setup-copilot-skills/action.yaml +++ b/setup-copilot-skills/action.yaml @@ -112,8 +112,8 @@ runs: # shellcheck disable=SC2086 set -- $line - if [ "$#" -lt 2 ]; then - echo "::error::Invalid skills entry: '$raw' — expected ' [@pin]'" + if [ "$#" -ne 2 ]; then + echo "::error::Invalid skills entry: '$raw' — expected exactly ' [@pin]' (got $# whitespace-separated tokens)" status=1 continue fi diff --git a/update-copilot-skills/action.yaml b/update-copilot-skills/action.yaml index aa7358a..da8a4f2 100644 --- a/update-copilot-skills/action.yaml +++ b/update-copilot-skills/action.yaml @@ -115,10 +115,14 @@ runs: # Snapshot SKILL.md contents before running so we can detect modifications # without relying on git being present in the caller's environment. + if ! command -v shasum >/dev/null 2>&1; then + echo "::error::'shasum' is required on the runner to detect SKILL.md changes but was not found on PATH." + exit 1 + fi before=$(mktemp) - find "$INPUT_DIR" -type f -name SKILL.md -print0 2>/dev/null \ + find "$INPUT_DIR" -type f -name SKILL.md -print0 \ | sort -z \ - | xargs -0 shasum -a 256 2>/dev/null >"$before" || true + | xargs -0 -r shasum -a 256 >"$before" out=$(mktemp) if ! gh skill update "${flags[@]}" 2>&1 | tee "$out"; then @@ -128,9 +132,9 @@ runs: fi after=$(mktemp) - find "$INPUT_DIR" -type f -name SKILL.md -print0 2>/dev/null \ + find "$INPUT_DIR" -type f -name SKILL.md -print0 \ | sort -z \ - | xargs -0 shasum -a 256 2>/dev/null >"$after" || true + | xargs -0 -r shasum -a 256 >"$after" if cmp -s "$before" "$after"; then echo "changed=false" >> "$GITHUB_OUTPUT" From 5badee1d2877450de4544ba9546dd4d9724d0271 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 19 Apr 2026 18:19:50 +0200 Subject: [PATCH 3/3] fix(setup-copilot-skills): fail when skills input has only comments The whitespace-only guard accepted inputs that consisted entirely of `#` comment lines, letting the action "succeed" without installing anything. Track whether any entry was actually processed and fail with a clear message when none were. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- setup-copilot-skills/action.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/setup-copilot-skills/action.yaml b/setup-copilot-skills/action.yaml index c187ef5..304fb1d 100644 --- a/setup-copilot-skills/action.yaml +++ b/setup-copilot-skills/action.yaml @@ -104,6 +104,7 @@ runs: installed_file=$(mktemp) status=0 + processed=0 while IFS= read -r raw || [ -n "$raw" ]; do line=${raw%%#*} @@ -137,8 +138,15 @@ runs: else printf '%s/%s\n' "$src" "$name" >> "$installed_file" fi + processed=$((processed + 1)) done <<<"$INPUT_SKILLS" + if [ "$processed" -eq 0 ] && [ "$status" -eq 0 ]; then + echo "::error::'skills' input contained no actionable entries (only blank lines or '#' comments)." + rm -f "$installed_file" + exit 1 + fi + { echo "installed-skills<