Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@

# Node (if scripts add deps later)
node_modules/

# Build artifact — regenerated by scripts/build-zip.sh
dist/
31 changes: 31 additions & 0 deletions scripts/build-zip.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Build dist/synter-plugin-<version>.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
# <archive-root>/.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"
120 changes: 120 additions & 0 deletions scripts/validate-manifests.js
Original file line number Diff line number Diff line change
@@ -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.');
Loading