Skip to content

ci: add claude plugin validate step to CI workflow#8

Merged
ropiteaux merged 6 commits into
mainfrom
ci-claude-plugin-validate
Jun 26, 2026
Merged

ci: add claude plugin validate step to CI workflow#8
ropiteaux merged 6 commits into
mainfrom
ci-claude-plugin-validate

Conversation

@ropiteaux

Copy link
Copy Markdown
Contributor

Summary

  • Install Claude Code in CI and run claude plugin validate against every plugin that has a .claude-plugin/plugin.json
  • claude plugin validate is purely static - no API key required, so no secrets needed in CI
  • Catches manifest errors (invalid JSON, missing required fields, unknown fields) on every PR

Test plan

  • CI passes on this PR
  • Confirm no ANTHROPIC_API_KEY secret is needed for the validate step

🤖 Generated with Claude Code

@ropiteaux ropiteaux requested a review from a team as a code owner June 25, 2026 13:10
Co-Authored-By: Claude <noreply@anthropic.com>
@ropiteaux ropiteaux force-pushed the ci-claude-plugin-validate branch from 2cd3e6f to daf4b0b Compare June 25, 2026 13:12
ropiteaux and others added 4 commits June 25, 2026 15:25
…SON check

Co-Authored-By: Claude <noreply@anthropic.com>
…manifest

Co-Authored-By: Claude <noreply@anthropic.com>
…tions

Also removes contentsquare-web from the Cursor marketplace catalog since
it has no .cursor-plugin/plugin.json (Claude Code only plugin).

Co-Authored-By: Claude <noreply@anthropic.com>
…rsor marketplace entry

Co-Authored-By: Claude <noreply@anthropic.com>
"tracking",
"setup"
],
"skills": [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node -e "const j=require('./$f'); if(!j.name) throw new Error('$f: missing name'); if(!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(j.name)) throw new Error('$f: name must be kebab-case');"
done

- name: Install Claude Code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is too much bash scripting for my taste here. If we really need to perform all these validations, we can move them to a js script so

  • Readable & maintainable: real JS with editor support, formatting, lintable; no triple-escaped node -e "..." strings.
  • Locally reproducible: node scripts/validate-plugins.mjs reproduces any CI failure; can also be wired into the husky pre-commit.
  • Removed the injection smell — globbing and file reads happen in JS via fs/path instead of interpolating $f into JS source.
  • Same coverage, less duplication — the per-step failed=0 … exit $failed pattern is gone; dropped the redundant standalone "Validate JSON manifests" step (folded in + covered by claude plugin validate).
steps:
      - uses: actions/checkout@v6

      - name: Validate manifests, catalogs, MCP configs and skills
        run: node scripts/validate-plugins.mjs

      - name: Install Claude Code
        run: npm install -g @anthropic-ai/claude-code

      - name: Validate Claude Code plugins
        run: |
          shopt -s nullglob
          for dir in plugins/*/; do
            [[ -f "${dir}.claude-plugin/plugin.json" ]] || continue
            echo "--- validating $dir ---"
            claude plugin validate "$dir"
          done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked Claude to extract it:

#!/usr/bin/env node
//
// Validate plugin manifests, catalogs, MCP configs and skill files.
//
// Run locally with `node scripts/validate-plugins.mjs` to reproduce CI failures.
// The Claude plugin contract itself is checked separately by `claude plugin
// validate`; this script covers the shape and cross-file rules that tool does
// not know about (cursor manifests, catalog consistency, logos, frontmatter).
//
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";

const KEBAB_CASE = /^[a-z0-9]+(-[a-z0-9]+)*$/;

const errors = [];

/** Record a failure against a file, printed in GitHub Actions error format. */
function fail(file, message) {
  errors.push({ file, message });
  console.error(`::error file=${file}::${message}`);
}

/** Parse JSON, recording a failure (and returning null) on syntax errors. */
function readJson(file) {
  try {
    return JSON.parse(readFileSync(file, "utf8"));
  } catch (e) {
    fail(file, `Invalid JSON: ${e.message}`);
    return null;
  }
}

/** Directories under plugins/, e.g. ["plugins/contentsquare", ...]. */
function pluginDirs() {
  return readdirSync("plugins", { withFileTypes: true })
    .filter((d) => d.isDirectory())
    .map((d) => join("plugins", d.name));
}

/** SKILL.md paths under plugins/<plugin>/skills/<skill>/SKILL.md. */
function skillFiles() {
  const files = [];
  for (const dir of pluginDirs()) {
    const skillsDir = join(dir, "skills");
    if (!existsSync(skillsDir)) continue;
    for (const skill of readdirSync(skillsDir, { withFileTypes: true })) {
      if (!skill.isDirectory()) continue;
      const file = join(skillsDir, skill.name, "SKILL.md");
      if (existsSync(file)) files.push(file);
    }
  }
  return files;
}

/** Every plugin manifest that exists, across both plugin systems. */
function manifestFiles() {
  const files = [];
  for (const dir of pluginDirs()) {
    for (const rel of [".claude-plugin/plugin.json", ".cursor-plugin/plugin.json"]) {
      const file = join(dir, rel);
      if (existsSync(file)) files.push(file);
    }
  }
  return files;
}

// --- Checks -----------------------------------------------------------------

/** Manifests and catalogs are valid JSON with a kebab-case `name`. */
function validateManifestNames() {
  const catalogs = [".claude-plugin/marketplace.json", ".cursor-plugin/marketplace.json"];
  for (const file of [...catalogs, ...manifestFiles()]) {
    const json = readJson(file);
    if (!json) continue;
    if (!json.name) fail(file, "missing name");
    else if (!KEBAB_CASE.test(json.name)) fail(file, `name must be kebab-case: "${json.name}"`);
  }
}

/** .mcp.json files declare at least one server with a url or command. */
function validateMcpConfigs() {
  for (const dir of pluginDirs()) {
    const file = join(dir, ".mcp.json");
    if (!existsSync(file)) continue;
    const json = readJson(file);
    if (!json) continue;
    if (!json.mcpServers || typeof json.mcpServers !== "object") {
      fail(file, "missing or invalid mcpServers object");
      continue;
    }
    const servers = Object.entries(json.mcpServers);
    if (servers.length === 0) {
      fail(file, "mcpServers must have at least one entry");
      continue;
    }
    for (const [name, cfg] of servers) {
      if (!cfg.url && !cfg.command) fail(file, `server "${name}" must have url or command`);
    }
  }
}

/** Each SKILL.md opens with closed YAML frontmatter containing a description. */
function validateSkillFrontmatter() {
  for (const file of skillFiles()) {
    const content = readFileSync(file, "utf8");
    if (!content.startsWith("---")) {
      fail(file, "missing YAML frontmatter (must start with ---)");
      continue;
    }
    const end = content.indexOf("---", 3);
    if (end === -1) {
      fail(file, "unclosed YAML frontmatter");
      continue;
    }
    if (!/^description\s*:/m.test(content.slice(3, end))) {
      fail(file, "missing required description field in frontmatter");
    }
  }
}

/** Catalog entries point at real plugin directories with the right manifest. */
function validateCatalogConsistency() {
  const catalogs = {
    ".claude-plugin/marketplace.json": ".claude-plugin/plugin.json",
    ".cursor-plugin/marketplace.json": ".cursor-plugin/plugin.json",
  };
  for (const [catalog, manifestRel] of Object.entries(catalogs)) {
    const json = readJson(catalog);
    if (!json) continue;
    for (const plugin of json.plugins ?? []) {
      const src = plugin.source.replace(/^\.\//, "");
      if (!existsSync(src)) {
        fail(catalog, `plugin "${plugin.name}" source path does not exist: ${src}`);
        continue;
      }
      if (!existsSync(join(src, manifestRel))) {
        fail(catalog, `plugin "${plugin.name}" missing ${manifestRel}`);
      }
    }
  }
}

/** A plugin's claude and cursor manifests agree on name and version. */
function validateCrossManifestConsistency() {
  for (const dir of pluginDirs()) {
    const claudeFile = join(dir, ".claude-plugin/plugin.json");
    const cursorFile = join(dir, ".cursor-plugin/plugin.json");
    if (!existsSync(claudeFile) || !existsSync(cursorFile)) continue;
    const claude = readJson(claudeFile);
    const cursor = readJson(cursorFile);
    if (!claude || !cursor) continue;
    if (claude.name !== cursor.name) {
      fail(claudeFile, `name mismatch: .claude-plugin "${claude.name}" vs .cursor-plugin "${cursor.name}"`);
    }
    if (claude.version && cursor.version && claude.version !== cursor.version) {
      fail(claudeFile, `version mismatch: .claude-plugin "${claude.version}" vs .cursor-plugin "${cursor.version}"`);
    }
  }
}

/** Any logo referenced by a cursor manifest exists on disk. */
function validateLogoAssets() {
  for (const dir of pluginDirs()) {
    const file = join(dir, ".cursor-plugin/plugin.json");
    if (!existsSync(file)) continue;
    const json = readJson(file);
    if (!json?.logo) continue;
    const logoPath = join(dir, json.logo);
    if (!existsSync(logoPath)) fail(file, `logo file not found: ${logoPath}`);
  }
}

// --- Run --------------------------------------------------------------------

validateManifestNames();
validateMcpConfigs();
validateSkillFrontmatter();
validateCatalogConsistency();
validateCrossManifestConsistency();
validateLogoAssets();

if (errors.length > 0) {
  console.error(`\n${errors.length} validation error(s) found.`);
  process.exit(1);
}
console.log("OK - all plugin manifests, catalogs, MCP configs and skills are valid.");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, let me change that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Co-Authored-By: Claude <noreply@anthropic.com>
@ropiteaux ropiteaux merged commit d1351c2 into main Jun 26, 2026
1 check passed
@ropiteaux ropiteaux deleted the ci-claude-plugin-validate branch June 26, 2026 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants