From 070a8875aac438fd9dc76e55ede7658d2b778d89 Mon Sep 17 00:00:00 2001
From: Adi El-Sammak <71227923+aelsammak@users.noreply.github.com>
Date: Wed, 12 Aug 2026 11:32:26 -0400
Subject: [PATCH 1/2] EBP-2849: Enforce plugin version bumps on release PRs and
marketplace sync (#2)
* EBP-2849: Enforce plugin version bumps on release PRs and marketplace sync
- Add tools/check-plugin-versions.sh requiring a strict semver bump in plugin.json when plugin content (excluding the top-level evals/ corpus) changes relative to the PR base
- Gate the version check to PRs targeting main, matching the EBP-2829 decision that bumps happen only in release PRs
- Validate marketplace.json stays in sync with plugins/ on every run: entries resolve to existing plugin dirs, names match, no duplicates, every plugin dir listed
- Wire both checks into the validate CI job as steps; checkout uses fetch-depth 0 so the merge base is reachable
* EBP-2849: Address Claude PR review fixes
* EBP-2849: Bump setup-node from Node 20 to Node 24
- Node 20 reached end of life in April 2026 and GitHub Actions runners remove it in fall 2026
- Node 24 is the active LTS and satisfies the Claude Code CLI requirement of Node 18 or newer
- Applies to both setup-node usages (validate and trigger-evals jobs)
---
.github/workflows/ci.yml | 48 ++++++-
README.md | 2 +-
tools/check-plugin-versions.sh | 255 +++++++++++++++++++++++++++++++++
3 files changed, 300 insertions(+), 5 deletions(-)
create mode 100755 tools/check-plugin-versions.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ff80ce0..e89c22e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,10 +3,17 @@ name: ci
# Parallel quality gates (D-16). No `needs:` between jobs so branch
# protection on `main` can require each independently:
# validate: CI-01: gh skill publish --dry-run + claude plugin validate (marketplace root)
+# + marketplace.json/plugins/ sync + version bumps on release PRs
+# + post-merge version backstop on pushes to main
# link-check: CI-02: the tools/check-links.sh sweep
# compile-check: EVAL-02 compile the baked JCSMP samples verbatim
# trigger-evals: lightweight skill-triggering check; self-skips green without a key
on:
+ # Deliberately no `edited` type: running on title/body edits either re-runs
+ # the paid eval matrix for a no-code change or, if jobs are gated, lets the
+ # edit supersede a red required check (skipped counts as success). Base
+ # retargeting to main is instead covered by the post-merge backstop below
+ # plus branch protection "require branches to be up to date" on main.
pull_request:
push:
branches: [main]
@@ -19,9 +26,12 @@ permissions:
contents: read
# Supersede stacked runs on the same ref: each trigger-evals leg spends real
-# API budget, so consecutive pushes cancel the previous run instead of queuing.
+# API budget, so consecutive PR pushes cancel the previous run instead of
+# queuing. Pushes to main group by SHA instead, so a backstop run is never
+# cancelled (or pending-replaced) by the next merge, which would leave its
+# pushed range unchecked and grey instead of red.
concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
+ group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }}
cancel-in-progress: true
jobs:
@@ -38,9 +48,11 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # version check: merge-base + `git show :` need real history
- uses: actions/setup-node@v4
with:
- node-version: 20
+ node-version: 24
- name: Install Claude Code CLI
run: npm install -g @anthropic-ai/claude-code
- name: Validate skills against the Agent Skills spec (gh skill publish dry run)
@@ -50,6 +62,34 @@ jobs:
# Validate the Claude plugin/marketplace manifests from the marketplace root (".").
- name: claude plugin validate (marketplace root)
run: claude plugin validate . --strict
+ # Every marketplace entry must resolve to a real plugin dir and every
+ # plugin dir must be listed (pure tree check, runs on every event).
+ - name: Marketplace sync check
+ run: ./tools/check-plugin-versions.sh --sync-only
+ # Version bumps happen only in release PRs: fork dev -> upstream
+ # SolaceProducts main. github.base_ref is the PR's target branch in
+ # whichever repo hosts the PR, so this step fires on those upstream
+ # release PRs (the enforcement point) and on the fork's post-release
+ # main sync (where it passes, the bump already landed). It never runs
+ # on PRs into dev, and base_ref is empty on push and workflow_dispatch,
+ # so it self-skips there too. Claude Code caches installed plugins by
+ # the plugin.json version, so an unbumped release never reaches users.
+ # origin/, not the event's base.sha: the latter goes stale when
+ # the base branch advances after the last PR event, silently widening
+ # the diff to other PRs' commits.
+ - name: Check plugin version bumps (release PRs)
+ if: github.base_ref == 'main'
+ run: ./tools/check-plugin-versions.sh "origin/${{ github.base_ref }}"
+ # Post-merge backstop: merging one release PR does not re-run another's
+ # checks (synchronize fires only on head pushes), so a second PR can
+ # merge on a stale green with a version main already ships. Re-checking
+ # the pushed merge against event.before (the pre-push tip) turns that
+ # red instead of silent. Prevention needs branch protection (up-to-date
+ # branches or a merge queue); `!created` skips the branch-creation push,
+ # where event.before is the all-zeros SHA.
+ - name: Check plugin version bumps (post-merge backstop)
+ if: github.event_name == 'push' && !github.event.created
+ run: ./tools/check-plugin-versions.sh "${{ github.event.before }}"
# CI-02: sweep the plugins/ tree for broken and soft-404 documentation links.
link-check:
@@ -104,7 +144,7 @@ jobs:
- uses: actions/setup-node@v4
if: env.HAS_KEY == 'true'
with:
- node-version: 20
+ node-version: 24
- name: Install Claude Code CLI
if: env.HAS_KEY == 'true'
run: npm install -g @anthropic-ai/claude-code
diff --git a/README.md b/README.md
index fbca63d..ae43d03 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ A collection of Solace AI agent plugins that package skills for building event-d
### `skills` CLI
-This lets you pick specific skills to install and supports most agents.
+This lets you pick specific skills to install. The skills in this repository follow the open [Agent Skills](https://agentskills.io) standard, and the [`skills` CLI](https://github.com/vercel-labs/skills) discovers skills in this format from any GitHub repository and installs them into a wide range of agents, including Claude Code, Cursor, Codex, GitHub Copilot, and Windsurf.
```shell
npx skills add SolaceProducts/agent-plugins
diff --git a/tools/check-plugin-versions.sh b/tools/check-plugin-versions.sh
new file mode 100755
index 0000000..3c5613f
--- /dev/null
+++ b/tools/check-plugin-versions.sh
@@ -0,0 +1,255 @@
+#!/usr/bin/env bash
+#
+# check-plugin-versions.sh — enforce the plugin release versioning rules and
+# keep marketplace.json in sync with the plugins/ tree.
+#
+# Claude Code caches an installed plugin by the "version" string in its
+# plugin.json: pushing new commits alone is NOT enough for users to receive
+# changes, the version must be bumped. Version bumps happen only in
+# release PRs (PRs targeting main), so CI runs the version check on those PRs
+# and the sync check on every run.
+#
+# Two checks:
+# * Version bump — for every plugin whose content changed relative to the
+# base (anything under plugins// except the top-level evals/ corpus,
+# which does not ship functionality), plugin.json "version" must be strict
+# semver MAJOR.MINOR.PATCH (no "v" prefix, no prerelease) and strictly
+# greater than the version at the base. A plugin that is new relative to
+# the base only needs a valid semver version.
+# * Marketplace sync — every .claude-plugin/marketplace.json entry must
+# resolve to an existing plugin directory whose plugin.json "name" matches
+# the entry, and every plugins/*/ directory must be listed as an entry.
+#
+# Usage: tools/check-plugin-versions.sh [base-ref] (default base: origin/main)
+# tools/check-plugin-versions.sh --sync-only (marketplace sync only)
+#
+# The version check diffs merge-base(base-ref, HEAD)..HEAD, so only this
+# branch's own committed changes count; commit local changes before running.
+# The old version comes from the base-ref tip, so a version the base
+# already ships fails the comparison.
+# In CI the base is origin/ and checkout uses fetch-depth: 0
+# so the merge base and the base-side plugin.json are reachable.
+#
+# Exit codes: 0 = all checks pass, 1 = violation(s) or unresolvable base.
+# Requires jq (preinstalled on ubuntu-latest runners).
+
+set -uo pipefail
+
+cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 1
+
+MARKETPLACE=".claude-plugin/marketplace.json"
+
+command -v jq >/dev/null 2>&1 || { echo "jq is required but not found" >&2; exit 1; }
+
+SYNC_ONLY=0
+BASE_REF="origin/main"
+if [[ "${1:-}" == "--sync-only" ]]; then
+ SYNC_ONLY=1
+elif [[ -n "${1:-}" ]]; then
+ BASE_REF="$1"
+fi
+
+errors=()
+
+# fail — collect the failure and annotate so the
+# message lands on the manifest in the PR Files view.
+fail() {
+ echo "::error file=$1::$2"
+ errors+=("$2")
+}
+
+# Strict MAJOR.MINOR.PATCH: no "v" prefix, no prerelease/build, no leading zeros.
+is_semver() {
+ local re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
+ [[ "$1" =~ $re ]]
+}
+
+# semver_gt — true only when a > b (equality fails; both pre-validated).
+semver_gt() {
+ local a1 a2 a3 b1 b2 b3
+ IFS=. read -r a1 a2 a3 <<<"$1"
+ IFS=. read -r b1 b2 b3 <<<"$2"
+ if [[ "$a1" -ne "$b1" ]]; then [[ "$a1" -gt "$b1" ]]; return; fi
+ if [[ "$a2" -ne "$b2" ]]; then [[ "$a2" -gt "$b2" ]]; return; fi
+ [[ "$a3" -gt "$b3" ]]
+}
+
+# --- Part A: version bumps for changed plugin content -----------------------
+
+if [[ "$SYNC_ONLY" -eq 0 ]]; then
+ if ! git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null; then
+ echo "::error::cannot resolve base ref '${BASE_REF}'"
+ echo "Cannot resolve base ref '${BASE_REF}'." >&2
+ echo "Locally: git fetch origin main (or pass a base ref). In CI: checkout needs fetch-depth: 0." >&2
+ exit 1
+ fi
+ # Unchecked, a merge-base failure would leave $merge_base empty, the diff
+ # below empty, and the whole check would silently pass.
+ if ! merge_base=$(git merge-base "$BASE_REF" HEAD); then
+ echo "::error::no common ancestor between '${BASE_REF}' and HEAD"
+ echo "No common ancestor between '${BASE_REF}' and HEAD (shallow clone or unrelated histories)." >&2
+ echo "Locally: use a full clone (git fetch --unshallow)." >&2
+ exit 1
+ fi
+
+ if [[ "$merge_base" == "$(git rev-parse HEAD)" ]]; then
+ echo "No commits beyond ${BASE_REF}; nothing to version-check."
+ else
+ # core.quotePath=false: git would otherwise C-quote non-ASCII paths
+ # ("plugins/x/caf\303\251.md"), which the sed extraction below would
+ # silently skip. --no-renames: a rename reports only its destination
+ # path, hiding the deletion side from the content filter below.
+ if ! changed=$(git -c core.quotePath=false diff --no-renames --name-only "$merge_base" HEAD -- plugins/); then
+ echo "::error::git diff ${merge_base}..HEAD failed"
+ exit 1
+ fi
+ plugin_names=$(printf '%s\n' "$changed" | sed -n 's|^plugins/\([^/]*\)/.*|\1|p' | sort -u)
+
+ # read -r, not `for ... in`: word splitting would break a plugin dir name
+ # containing whitespace into fragments and silently skip it.
+ while IFS= read -r name; do
+ [[ -n "$name" ]] || continue
+ manifest="plugins/${name}/.claude-plugin/plugin.json"
+
+ # Deleted plugin: nothing to version; Part B flags the stale marketplace entry.
+ if [[ ! -d "plugins/${name}" ]]; then
+ echo "plugins/${name}: directory removed, skipping version check."
+ continue
+ fi
+
+ # Content = changed files under the plugin minus its top-level evals/
+ # corpus (the corpus does not ship functionality to users).
+ content=""
+ while IFS= read -r f; do
+ case "$f" in
+ "plugins/${name}/evals/"*) ;;
+ "plugins/${name}/"*) content+="${f}"$'\n' ;;
+ esac
+ done <<<"$changed"
+
+ if [[ -z "$content" ]]; then
+ echo "plugins/${name}: only evals/ changed, no version bump required."
+ continue
+ fi
+ content_count=$(printf '%s' "$content" | grep -c .)
+
+ if [[ ! -f "$manifest" ]]; then
+ fail "$manifest" "plugins/${name}: ${manifest} is missing"
+ continue
+ fi
+ new_ver=$(jq -r '.version // empty' "$manifest" 2>/dev/null)
+ if [[ -z "$new_ver" ]]; then
+ fail "$manifest" "plugins/${name}: plugin.json has no \"version\" field; set a semver version (new plugins start at 0.1.0)"
+ continue
+ fi
+ if ! is_semver "$new_ver"; then
+ fail "$manifest" "plugins/${name}: version '${new_ver}' is not strict semver MAJOR.MINOR.PATCH (no 'v' prefix, no prerelease)"
+ continue
+ fi
+
+ # Old version from the base-ref tip, not the merge base: a version the
+ # base already ships would leave users' cached installs stale even if
+ # it beats the fork-point version.
+ old_manifest=$(git show "${BASE_REF}:${manifest}" 2>/dev/null) || old_manifest=""
+ if [[ -z "$old_manifest" ]]; then
+ echo "plugins/${name}: new plugin at ${new_ver}, OK."
+ continue
+ fi
+ old_ver=$(printf '%s' "$old_manifest" | jq -r '.version // empty' 2>/dev/null)
+ if [[ -z "$old_ver" ]] || ! is_semver "$old_ver"; then
+ echo "::warning file=${manifest}::plugins/${name}: base version '${old_ver}' is not strict semver; accepting ${new_ver} without comparison"
+ continue
+ fi
+
+ if semver_gt "$new_ver" "$old_ver"; then
+ echo "plugins/${name}: ${old_ver} -> ${new_ver} covers ${content_count} changed content file(s), OK."
+ else
+ fail "$manifest" "plugins/${name}: ${content_count} content file(s) changed but version did not increase (${old_ver} -> ${new_ver}); bump \"version\" in ${manifest} so users receive the changes"
+ echo " changed content files (first 10):"
+ printf '%s' "$content" | head -n 10 | sed 's/^/ x /'
+ fi
+ done <<<"$plugin_names"
+ fi
+fi
+
+# --- Part B: marketplace.json <-> plugins/ sync ------------------------------
+
+if [[ ! -f "$MARKETPLACE" ]]; then
+ fail "$MARKETPLACE" "marketplace manifest ${MARKETPLACE} is missing"
+# Reject an unparseable manifest up front with a specific error; the checked
+# extraction below would also catch it, but with a less specific message.
+elif ! jq -e '.plugins | type == "array"' "$MARKETPLACE" >/dev/null 2>&1; then
+ fail "$MARKETPLACE" "cannot parse ${MARKETPLACE} (invalid JSON or missing .plugins array)"
+# Captured, not process-substituted: `done < <(jq ...)` hides a mid-stream
+# @tsv failure (non-scalar name), silently passing on partial rows.
+elif ! entries=$(jq -r '.plugins[] | [.name, (.source | type), (.source | tostring)] | @tsv' "$MARKETPLACE"); then
+ fail "$MARKETPLACE" "cannot extract entries from ${MARKETPLACE} (every entry \"name\" must be a JSON scalar)"
+else
+ # Newline-separated source list for the reverse (directory -> entry) check.
+ sources=""
+ while IFS=$'\t' read -r ename etype esrc; do
+ # An empty .plugins array reaches the loop as one empty here-string line.
+ [[ -n "$etype" ]] || continue
+ if [[ "$etype" != "string" ]]; then
+ fail "$MARKETPLACE" "marketplace entry '${ename}': non-string source is not supported by this check; update tools/check-plugin-versions.sh if the format changed"
+ continue
+ fi
+ src="${esrc#./}"; src="${src%/}"
+ sources+="${src}"$'\n'
+ if [[ ! -d "$src" ]]; then
+ fail "$MARKETPLACE" "marketplace entry '${ename}': source '${esrc}' does not exist"
+ continue
+ fi
+ pmanifest="${src}/.claude-plugin/plugin.json"
+ if [[ ! -f "$pmanifest" ]]; then
+ fail "$MARKETPLACE" "marketplace entry '${ename}': ${pmanifest} is missing"
+ continue
+ fi
+ pname=$(jq -r '.name // empty' "$pmanifest" 2>/dev/null)
+ if [[ "$pname" != "$ename" ]]; then
+ fail "$pmanifest" "marketplace entry name '${ename}' does not match plugin.json name '${pname}'"
+ fi
+ done <<<"$entries"
+
+ # Duplicate entry names or sources hide one plugin behind another. Checked
+ # jq exit so a failed query is not "no duplicates"; read -r so names with
+ # whitespace stay whole.
+ if ! dup_names=$(jq -r '[.plugins[].name] | group_by(.)[] | select(length > 1)[0]' "$MARKETPLACE"); then
+ fail "$MARKETPLACE" "duplicate-name check failed to run against ${MARKETPLACE}"
+ fi
+ while IFS= read -r d; do
+ [[ -n "$d" ]] || continue
+ fail "$MARKETPLACE" "marketplace has duplicate entries named '${d}'"
+ done <<<"$dup_names"
+ if ! dup_sources=$(jq -r '[.plugins[].source | tostring] | group_by(.)[] | select(length > 1)[0]' "$MARKETPLACE"); then
+ fail "$MARKETPLACE" "duplicate-source check failed to run against ${MARKETPLACE}"
+ fi
+ while IFS= read -r d; do
+ [[ -n "$d" ]] || continue
+ fail "$MARKETPLACE" "marketplace has duplicate entries with source '${d}'"
+ done <<<"$dup_sources"
+
+ # Reverse direction: every plugin directory must be listed.
+ for dir in plugins/*/; do
+ [[ -d "$dir" ]] || continue # unexpanded glob when plugins/ is empty
+ dir="${dir%/}"
+ if ! printf '%s' "$sources" | grep -qxF "$dir"; then
+ fail "$MARKETPLACE" "plugin directory '${dir}' has no entry in ${MARKETPLACE}"
+ fi
+ done
+fi
+
+# --- Summary -----------------------------------------------------------------
+
+if [[ ${#errors[@]} -gt 0 ]]; then
+ echo ""
+ echo "Plugin version/marketplace violations found:"
+ printf ' x %s\n' "${errors[@]}"
+ exit 1
+fi
+
+if [[ "$SYNC_ONLY" -eq 1 ]]; then
+ echo "marketplace.json is in sync with plugins/."
+else
+ echo "All changed plugins version-checked against ${BASE_REF}; marketplace.json is in sync with plugins/."
+fi
From 6b1fd62147e05cba3554dfc46a5f59d374e2e05a Mon Sep 17 00:00:00 2001
From: Adi El-Sammak <71227923+aelsammak@users.noreply.github.com>
Date: Thu, 13 Aug 2026 08:43:42 -0400
Subject: [PATCH 2/2] DATAGO-147743: Pin CI actions to commit SHAs and pin the
Claude Code CLI install (#3)
- Pin actions/checkout, setup-node, setup-java, and upload-artifact to the full commit SHAs their v4 tags currently resolve to, with the tag in a trailing comment
- Pin the Claude Code CLI install to 2.1.231 via a workflow-level CLAUDE_CODE_VERSION env value shared by both install steps
- Mutable tags and unpinned npm installs let a compromised upstream publish execute in CI; the trigger-evals job holds the ANTHROPIC_API_KEY secret
---
.github/workflows/ci.yml | 26 ++++++++++++++++----------
1 file changed, 16 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e89c22e..5b89401 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,6 +25,12 @@ on:
permissions:
contents: read
+# Pin the Claude Code CLI install to an exact version so CI never auto-pulls a
+# fresh npm publish into a job that can hold the ANTHROPIC_API_KEY secret.
+# Defined once so the two install steps cannot drift; bump deliberately.
+env:
+ CLAUDE_CODE_VERSION: 2.1.231
+
# Supersede stacked runs on the same ref: each trigger-evals leg spends real
# API budget, so consecutive PR pushes cancel the previous run instead of
# queuing. Pushes to main group by SHA instead, so a backstop run is never
@@ -47,14 +53,14 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0 # version check: merge-base + `git show :` need real history
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
- name: Install Claude Code CLI
- run: npm install -g @anthropic-ai/claude-code
+ run: npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
- name: Validate skills against the Agent Skills spec (gh skill publish dry run)
env:
GH_TOKEN: ${{ github.token }}
@@ -96,7 +102,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Link-check the plugins tree
run: ./tools/check-links.sh plugins
@@ -106,8 +112,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-java@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
with:
distribution: temurin
java-version: 11
@@ -140,14 +146,14 @@ jobs:
- name: Warn when skipped (no ANTHROPIC_API_KEY)
if: env.HAS_KEY != 'true'
run: echo "::warning::trigger-evals self-skipped, the ANTHROPIC_API_KEY secret is not configured; this green check asserted nothing"
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
if: env.HAS_KEY == 'true'
with:
node-version: 24
- name: Install Claude Code CLI
if: env.HAS_KEY == 'true'
- run: npm install -g @anthropic-ai/claude-code
+ run: npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
- name: Run trigger evals for ${{ matrix.model }}
if: env.HAS_KEY == 'true'
env:
@@ -158,7 +164,7 @@ jobs:
# runner VM is destroyed, so upload them where they can be inspected.
- name: Upload transcripts on failure
if: failure()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: trigger-eval-transcripts-${{ matrix.model }}
path: ${{ runner.temp }}/trigger-evals