diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..63a3daf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release + +on: + push: + tags: + - 'synter--v*' + +permissions: + contents: write + +jobs: + release: + name: Validate, build, and publish release zip + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run manifest validation + run: node scripts/validate-manifests.js + + - name: Ensure zip/rsync are available + run: | + command -v zip >/dev/null || sudo apt-get update -qq && sudo apt-get install -y -qq zip + command -v rsync >/dev/null || sudo apt-get update -qq && sudo apt-get install -y -qq rsync + + - name: Build plugin zip + run: bash scripts/build-zip.sh + + - name: Resolve version + id: version + run: echo "version=$(node -p "require('./.claude-plugin/plugin.json').version")" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/synter-plugin-${{ steps.version.outputs.version }}.zip + generate_release_notes: true diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..3d1a10c --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,22 @@ +name: Validate + +on: + pull_request: + branches: + - main + +jobs: + validate-manifests: + name: Validate manifests, skills, and interpolation guards + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run manifest validation + run: node scripts/validate-manifests.js diff --git a/.gitignore b/.gitignore index 57bf85f..39a366e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ # Node (if scripts add deps later) node_modules/ + +# Build artifact — regenerated by scripts/build-zip.sh +dist/ diff --git a/scripts/build-zip.sh b/scripts/build-zip.sh new file mode 100755 index 0000000..3bfde49 --- /dev/null +++ b/scripts/build-zip.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Build dist/synter-plugin-.zip with the plugin at the archive root — +# exactly the layout Claude Desktop's "Upload plugin" zip import expects. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +VERSION=$(node -p "require('./.claude-plugin/plugin.json').version") +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT + +# Plugin surface only — dev/consumption paths stay out of the artifact. +rsync -a \ + --exclude '.git' --exclude '.github' --exclude 'dist' \ + --exclude 'sdk' --exclude '.cursor-plugin' \ + ./ "$STAGE/synter/" + +mkdir -p dist +ZIP_PATH="dist/synter-plugin-$VERSION.zip" +rm -f "$ZIP_PATH" +(cd "$STAGE" && zip -qr "$REPO_ROOT/$ZIP_PATH" synter) + +echo "Built $ZIP_PATH" + +# Hard root-check: Claude Desktop's zip upload requires the manifest at +# /.claude-plugin/plugin.json, not nested under a repo folder. +unzip -l "$ZIP_PATH" | grep -q 'synter/.claude-plugin/plugin.json' \ + || { echo "FATAL: plugin.json not at archive root" >&2; exit 1; } + +echo "OK: synter/.claude-plugin/plugin.json present at archive root" diff --git a/scripts/validate-manifests.js b/scripts/validate-manifests.js new file mode 100644 index 0000000..8424abb --- /dev/null +++ b/scripts/validate-manifests.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +// Validates the plugin's manifests and skill frontmatter before a PR merges. +// Node stdlib only — no dependencies to keep CI fast and reproducible. +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const errors = []; + +function readFile(relPath) { + const abs = path.join(REPO_ROOT, relPath); + return fs.readFileSync(abs, 'utf8'); +} + +function parseJson(relPath) { + const raw = readFile(relPath); + try { + return JSON.parse(raw); + } catch (err) { + errors.push(`${relPath}: invalid JSON — ${err.message}`); + return null; + } +} + +// (a) Parse-validate every JSON manifest. +const MANIFEST_PATHS = [ + '.claude-plugin/plugin.json', + '.claude-plugin/marketplace.json', + '.cursor-plugin/plugin.json', + '.cursor-plugin/marketplace.json', + '.mcp.json', + 'mcp.json', +]; + +const manifests = {}; +for (const relPath of MANIFEST_PATHS) { + manifests[relPath] = parseJson(relPath); +} + +// (b) marketplace.json plugins[0].name must match plugin.json name. +const pluginJson = manifests['.claude-plugin/plugin.json']; +const marketplaceJson = manifests['.claude-plugin/marketplace.json']; +if (pluginJson && marketplaceJson) { + const marketplaceName = marketplaceJson.plugins && marketplaceJson.plugins[0] && marketplaceJson.plugins[0].name; + if (marketplaceName !== pluginJson.name) { + errors.push( + `.claude-plugin/marketplace.json: plugins[0].name ("${marketplaceName}") does not match ` + + `.claude-plugin/plugin.json name ("${pluginJson.name}")` + ); + } +} + +// (c) Skill frontmatter check: name matches directory, description present. +const SKILLS_DIR = path.join(REPO_ROOT, 'skills'); +if (fs.existsSync(SKILLS_DIR)) { + const skillDirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + for (const dirName of skillDirs) { + const skillPath = path.join('skills', dirName, 'SKILL.md'); + const absSkillPath = path.join(REPO_ROOT, skillPath); + if (!fs.existsSync(absSkillPath)) { + errors.push(`${skillPath}: missing`); + continue; + } + + const content = fs.readFileSync(absSkillPath, 'utf8'); + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) { + errors.push(`${skillPath}: missing frontmatter block (--- ... ---)`); + continue; + } + + const frontmatter = frontmatterMatch[1]; + const nameMatch = frontmatter.match(/^name:\s*(.+)$/m); + const descriptionMatch = frontmatter.match(/^description:\s*(.+)$/m); + + const name = nameMatch ? nameMatch[1].trim() : null; + const description = descriptionMatch ? descriptionMatch[1].trim() : null; + + if (!name) { + errors.push(`${skillPath}: frontmatter missing "name"`); + } else if (name !== dirName) { + errors.push(`${skillPath}: frontmatter name ("${name}") does not match directory ("${dirName}")`); + } + + if (!description) { + errors.push(`${skillPath}: frontmatter missing "description"`); + } + } +} else { + errors.push('skills/: directory not found'); +} + +// (d) Interpolation guards — the two mcp manifests must reference their +// respective client's variable substitution syntax, or a client's install +// UI silently ships a dead API key. +const mcpDotJson = readFile('.mcp.json'); +if (!mcpDotJson.includes('user_config.synter_api_key')) { + errors.push('.mcp.json: missing required literal "user_config.synter_api_key"'); +} + +const mcpJson = readFile('mcp.json'); +if (!mcpJson.includes('SYNTER_API_KEY')) { + errors.push('mcp.json: missing required literal "SYNTER_API_KEY"'); +} + +if (errors.length > 0) { + console.error('Manifest validation failed:\n'); + for (const err of errors) { + console.error(` - ${err}`); + } + console.error(`\n${errors.length} error(s).`); + process.exit(1); +} + +console.log('All manifests, skill frontmatter, and interpolation guards passed.');