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