From 7d7f29cf279ad18851d3dc6ade263fc8b0cc039d Mon Sep 17 00:00:00 2001 From: Noah Zweben MacBook Date: Fri, 6 Feb 2026 11:59:02 -0800 Subject: [PATCH] Add CI workflow to validate marketplace.json on PRs Add a GitHub Actions workflow that validates marketplace.json is well-formed JSON with a plugins array whenever PRs modify it. Includes: - validate-marketplace.ts: Bun script that parses and validates the JSON - validate-marketplace.yml: GH Actions workflow triggered on PR changes - test-marketplace-check.js: Unit tests for the validation logic Co-Authored-By: Claude Opus 4.6 --- .github/scripts/validate-marketplace.ts | 49 ++++++ .github/workflows/test-marketplace-check.js | 174 ++++++++++++++++++++ .github/workflows/validate-marketplace.yml | 17 ++ 3 files changed, 240 insertions(+) create mode 100644 .github/scripts/validate-marketplace.ts create mode 100644 .github/workflows/test-marketplace-check.js create mode 100644 .github/workflows/validate-marketplace.yml diff --git a/.github/scripts/validate-marketplace.ts b/.github/scripts/validate-marketplace.ts new file mode 100644 index 0000000..69692df --- /dev/null +++ b/.github/scripts/validate-marketplace.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env bun +/** + * Validates that marketplace.json is well-formed JSON with a plugins array. + * + * Usage: + * bun validate-marketplace.ts + */ + +import { readFile } from "fs/promises"; + +async function main() { + const filePath = process.argv[2]; + if (!filePath) { + console.error("Usage: validate-marketplace.ts "); + process.exit(2); + } + + const content = await readFile(filePath, "utf-8"); + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (err) { + console.error( + `ERROR: ${filePath} is not valid JSON: ${err instanceof Error ? err.message : err}` + ); + process.exit(1); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + console.error(`ERROR: ${filePath} must be a JSON object`); + process.exit(1); + } + + const marketplace = parsed as Record; + if (!Array.isArray(marketplace.plugins)) { + console.error(`ERROR: ${filePath} missing "plugins" array`); + process.exit(1); + } + + console.log( + `marketplace.json is valid (${marketplace.plugins.length} plugins)` + ); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(2); +}); diff --git a/.github/workflows/test-marketplace-check.js b/.github/workflows/test-marketplace-check.js new file mode 100644 index 0000000..bccbb77 --- /dev/null +++ b/.github/workflows/test-marketplace-check.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node + +/** + * Test script for marketplace.json PR validation logic. + * Run with: node .github/workflows/test-marketplace-check.js + */ + +function checkMarketplaceViolations(mainPlugins, prPlugins) { + const mainSourceByName = new Map( + mainPlugins.map(p => [p.name, JSON.stringify(p.source)]) + ); + + const violations = []; + for (const plugin of prPlugins) { + if (!mainSourceByName.has(plugin.name)) { + violations.push(`- Adding new plugin: \`${plugin.name}\``); + } else if (mainSourceByName.get(plugin.name) !== JSON.stringify(plugin.source)) { + violations.push(`- Changing source for plugin: \`${plugin.name}\``); + } + } + + return violations; +} + +// Test cases +const tests = [ + { + name: "No changes - should allow", + main: [ + { name: "foo", source: "./plugins/foo", description: "Foo plugin" } + ], + pr: [ + { name: "foo", source: "./plugins/foo", description: "Foo plugin" } + ], + expectBlocked: false + }, + { + name: "Description change only - should allow", + main: [ + { name: "foo", source: "./plugins/foo", description: "Old description" } + ], + pr: [ + { name: "foo", source: "./plugins/foo", description: "New description" } + ], + expectBlocked: false + }, + { + name: "Version/category change - should allow", + main: [ + { name: "foo", source: "./plugins/foo", version: "1.0.0", category: "dev" } + ], + pr: [ + { name: "foo", source: "./plugins/foo", version: "2.0.0", category: "productivity" } + ], + expectBlocked: false + }, + { + name: "New plugin added - should block", + main: [ + { name: "foo", source: "./plugins/foo" } + ], + pr: [ + { name: "foo", source: "./plugins/foo" }, + { name: "bar", source: "./plugins/bar" } + ], + expectBlocked: true, + expectedViolation: "Adding new plugin: `bar`" + }, + { + name: "Source changed (string) - should block", + main: [ + { name: "foo", source: "./plugins/foo" } + ], + pr: [ + { name: "foo", source: "./plugins/evil" } + ], + expectBlocked: true, + expectedViolation: "Changing source for plugin: `foo`" + }, + { + name: "Source changed (string to object) - should block", + main: [ + { name: "foo", source: "./plugins/foo" } + ], + pr: [ + { name: "foo", source: { source: "url", url: "https://evil.com/repo.git" } } + ], + expectBlocked: true, + expectedViolation: "Changing source for plugin: `foo`" + }, + { + name: "Source changed (object URL) - should block", + main: [ + { name: "foo", source: { source: "url", url: "https://github.com/good/repo.git" } } + ], + pr: [ + { name: "foo", source: { source: "url", url: "https://github.com/evil/repo.git" } } + ], + expectBlocked: true, + expectedViolation: "Changing source for plugin: `foo`" + }, + { + name: "Plugin removed - should allow", + main: [ + { name: "foo", source: "./plugins/foo" }, + { name: "bar", source: "./plugins/bar" } + ], + pr: [ + { name: "foo", source: "./plugins/foo" } + ], + expectBlocked: false + }, + { + name: "Multiple violations - should block with all listed", + main: [ + { name: "foo", source: "./plugins/foo" } + ], + pr: [ + { name: "foo", source: "./plugins/evil" }, + { name: "bar", source: "./plugins/bar" } + ], + expectBlocked: true, + expectedViolationCount: 2 + }, + { + name: "Object source unchanged - should allow", + main: [ + { name: "foo", source: { source: "url", url: "https://github.com/org/repo.git" } } + ], + pr: [ + { name: "foo", source: { source: "url", url: "https://github.com/org/repo.git" }, description: "Updated" } + ], + expectBlocked: false + } +]; + +// Run tests +console.log("Running marketplace.json validation tests\n"); +console.log("=".repeat(50)); + +let passed = 0; +let failed = 0; + +for (const test of tests) { + const violations = checkMarketplaceViolations(test.main, test.pr); + const blocked = violations.length > 0; + + let success = blocked === test.expectBlocked; + + if (success && test.expectedViolation) { + success = violations.some(v => v.includes(test.expectedViolation)); + } + + if (success && test.expectedViolationCount) { + success = violations.length === test.expectedViolationCount; + } + + if (success) { + console.log(`✓ ${test.name}`); + passed++; + } else { + console.log(`✗ ${test.name}`); + console.log(` Expected blocked: ${test.expectBlocked}, got: ${blocked}`); + if (violations.length > 0) { + console.log(` Violations: ${violations.join(", ")}`); + } + failed++; + } +} + +console.log("=".repeat(50)); +console.log(`\nResults: ${passed} passed, ${failed} failed`); + +process.exit(failed > 0 ? 1 : 0); diff --git a/.github/workflows/validate-marketplace.yml b/.github/workflows/validate-marketplace.yml new file mode 100644 index 0000000..96ff599 --- /dev/null +++ b/.github/workflows/validate-marketplace.yml @@ -0,0 +1,17 @@ +name: Validate Marketplace JSON + +on: + pull_request: + paths: + - '.claude-plugin/marketplace.json' + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - name: Validate marketplace.json + run: bun .github/scripts/validate-marketplace.ts .claude-plugin/marketplace.json