feat: reusable CI workflows for plugin, workspace template, and org template validation

Three reusable GitHub Actions workflows:
- validate-plugin.yml: plugin.yaml schema, content check, secrets scan
- validate-workspace-template.yml: config.yaml, adapter, Dockerfile build, secrets
- validate-org-template.yml: org.yaml hierarchy, files_dir references, secrets

Usage: `uses: Molecule-AI/molecule-ci/.github/workflows/validate-*.yml@main`
This commit is contained in:
Hongming Wang 2026-04-16 04:42:16 -07:00
commit f035b6e108
4 changed files with 429 additions and 0 deletions

View File

@ -0,0 +1,137 @@
name: Validate Org Template
on:
workflow_call:
inputs:
python-version:
type: string
default: "3.11"
jobs:
validate:
name: Org template validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Validate org.yaml exists
run: |
if [ ! -f org.yaml ]; then
echo "::error::org.yaml not found at repo root"
exit 1
fi
- name: Validate org.yaml schema
run: |
pip install pyyaml -q
python3 << 'PYEOF'
import yaml, sys
with open("org.yaml") as f:
org = yaml.safe_load(f)
errors = []
# Required top-level fields
if not org.get("name"):
errors.append("Missing required field: name")
# Must have workspaces or defaults
if not org.get("workspaces") and not org.get("defaults"):
errors.append("org.yaml must have at least 'workspaces' or 'defaults'")
# Schema version
# Check in defaults or top-level
sv = org.get("template_schema_version")
if sv is None and org.get("defaults"):
sv = org["defaults"].get("template_schema_version")
if sv is None:
print("::warning::No template_schema_version found (recommended: add template_schema_version: 1)")
# Validate workspace structure
def validate_workspace(ws, path=""):
ws_errors = []
name = ws.get("name", "<unnamed>")
full = f"{path}/{name}" if path else name
if not ws.get("name"):
ws_errors.append(f"Workspace at {full}: missing 'name'")
# Validate plugins are strings
plugins = ws.get("plugins", [])
if plugins and not isinstance(plugins, list):
ws_errors.append(f"{full}: 'plugins' must be a list")
# Validate children recursively
for child in ws.get("children", []):
ws_errors.extend(validate_workspace(child, full))
return ws_errors
for ws in org.get("workspaces", []):
errors.extend(validate_workspace(ws))
if errors:
for e in errors:
print(f"::error::{e}")
sys.exit(1)
# Count workspaces
def count_ws(nodes):
c = 0
for n in nodes:
c += 1
c += count_ws(n.get("children", []))
return c
total = count_ws(org.get("workspaces", []))
print(f"✓ org.yaml valid: {org['name']} ({total} workspaces)")
PYEOF
- name: Validate per-role directories
run: |
pip install pyyaml -q
python3 << 'PYEOF'
import yaml, os
with open("org.yaml") as f:
org = yaml.safe_load(f)
warnings = 0
def check_files_dir(ws, path=""):
nonlocal warnings
name = ws.get("name", "")
files_dir = ws.get("files_dir", "")
if files_dir:
if not os.path.isdir(files_dir):
print(f"::warning::files_dir '{files_dir}' for {name} does not exist as a directory")
warnings += 1
elif not os.path.isfile(os.path.join(files_dir, "system-prompt.md")):
print(f"::warning::{files_dir}/system-prompt.md missing for {name}")
warnings += 1
else:
print(f"✓ {files_dir}/system-prompt.md exists for {name}")
for child in ws.get("children", []):
check_files_dir(child, f"{path}/{name}")
for ws in org.get("workspaces", []):
check_files_dir(ws)
if warnings:
print(f"\n{warnings} warning(s) — some files_dir references may be missing")
else:
print("✓ All files_dir references valid")
PYEOF
- name: Check for secrets
run: |
# Skip .env files (they're supposed to have placeholders)
if grep -rE "(sk-ant-|sk_test_|ghp_|AKIA[A-Z0-9])" --include="*.yaml" --include="*.yml" --include="*.md" --include="*.py" --include="*.sh" . 2>/dev/null | grep -v ".env"; then
echo "::error::Potential secret found in committed files"
exit 1
fi
echo "✓ No secrets detected"

105
.github/workflows/validate-plugin.yml vendored Normal file
View File

@ -0,0 +1,105 @@
name: Validate Plugin
on:
workflow_call:
inputs:
python-version:
type: string
default: "3.11"
jobs:
validate:
name: Plugin validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Validate plugin.yaml exists
run: |
if [ ! -f plugin.yaml ]; then
echo "::error::plugin.yaml not found at repo root"
exit 1
fi
- name: Validate plugin.yaml schema
run: |
pip install pyyaml -q
python3 << 'PYEOF'
import yaml, sys
with open("plugin.yaml") as f:
plugin = yaml.safe_load(f)
errors = []
# Required fields
for field in ["name", "version", "description"]:
if not plugin.get(field):
errors.append(f"Missing required field: {field}")
# Version format
v = plugin.get("version", "")
if v and not all(c in "0123456789." for c in str(v)):
errors.append(f"Invalid version format: {v} (expected semver-like)")
# Runtimes should be a list if present
runtimes = plugin.get("runtimes")
if runtimes is not None and not isinstance(runtimes, list):
errors.append(f"runtimes must be a list, got {type(runtimes).__name__}")
if errors:
for e in errors:
print(f"::error::{e}")
sys.exit(1)
print(f"✓ plugin.yaml valid: {plugin['name']} v{plugin['version']}")
if runtimes:
print(f" Runtimes: {', '.join(runtimes)}")
PYEOF
- name: Validate plugin has content
run: |
# Must have at least one of: SKILL.md, hooks/, skills/, rules/
found=0
for path in SKILL.md hooks skills rules; do
if [ -e "$path" ]; then
echo "✓ Found: $path"
found=$((found + 1))
fi
done
if [ "$found" -eq 0 ]; then
echo "::error::Plugin must contain at least one of: SKILL.md, hooks/, skills/, rules/"
exit 1
fi
echo "✓ Plugin has $found content entries"
- name: Validate SKILL.md format (if present)
run: |
if [ -f SKILL.md ]; then
# Must start with # heading
first_line=$(head -1 SKILL.md)
if [[ ! "$first_line" =~ ^#\ ]]; then
echo "::warning::SKILL.md should start with a # heading"
fi
echo "✓ SKILL.md present ($(wc -l < SKILL.md) lines)"
fi
- name: Check for common mistakes
run: |
# No secrets in committed files
if grep -rE "(sk-ant-|sk_test_|ghp_|AKIA[A-Z0-9])" --include="*.yaml" --include="*.yml" --include="*.md" --include="*.py" --include="*.sh" . 2>/dev/null; then
echo "::error::Potential secret found in committed files"
exit 1
fi
echo "✓ No secrets detected"
# No node_modules or __pycache__ committed
if [ -d node_modules ] || [ -d __pycache__ ]; then
echo "::error::node_modules or __pycache__ should not be committed"
exit 1
fi
echo "✓ No build artifacts committed"

View File

@ -0,0 +1,108 @@
name: Validate Workspace Template
on:
workflow_call:
inputs:
python-version:
type: string
default: "3.11"
jobs:
validate:
name: Template validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Validate config.yaml exists
run: |
if [ ! -f config.yaml ]; then
echo "::error::config.yaml not found at repo root"
exit 1
fi
- name: Validate config.yaml schema
run: |
pip install pyyaml -q
python3 << 'PYEOF'
import yaml, sys
with open("config.yaml") as f:
config = yaml.safe_load(f)
errors = []
# Required fields
if not config.get("name"):
errors.append("Missing required field: name")
if not config.get("runtime"):
errors.append("Missing required field: runtime")
# Known runtimes
known = {"langgraph", "claude-code", "crewai", "autogen", "deepagents", "hermes", "gemini-cli", "openclaw"}
runtime = config.get("runtime", "")
if runtime and runtime not in known:
print(f"::warning::Runtime '{runtime}' is not in the known set ({', '.join(sorted(known))}). This is OK for custom runtimes.")
# Schema version
sv = config.get("template_schema_version")
if sv is None:
errors.append("Missing template_schema_version (add: template_schema_version: 1)")
elif sv != 1:
print(f"::warning::template_schema_version is {sv}, expected 1")
if errors:
for e in errors:
print(f"::error::{e}")
sys.exit(1)
print(f"✓ config.yaml valid: {config['name']} (runtime: {config.get('runtime')})")
PYEOF
- name: Validate adapter exists
run: |
if [ -f adapter.py ]; then
echo "✓ adapter.py found"
# Check it imports from molecule_runtime
if grep -q "molecule_runtime\|from adapters" adapter.py; then
echo "✓ adapter.py imports runtime base"
else
echo "::warning::adapter.py doesn't import from molecule_runtime — may use legacy imports"
fi
else
echo "::notice::No adapter.py — this template uses config-only mode (inherits default adapter for its runtime)"
fi
- name: Validate Dockerfile (if present)
run: |
if [ -f Dockerfile ]; then
echo "✓ Dockerfile found"
# Must install molecule-ai-workspace-runtime
if grep -q "molecule-ai-workspace-runtime" Dockerfile requirements.txt 2>/dev/null; then
echo "✓ Depends on molecule-ai-workspace-runtime"
else
echo "::warning::Dockerfile/requirements.txt doesn't reference molecule-ai-workspace-runtime"
fi
# Must set ADAPTER_MODULE or equivalent
if grep -q "ADAPTER_MODULE" Dockerfile; then
echo "✓ ADAPTER_MODULE env set"
fi
fi
- name: Docker build smoke test
if: hashFiles('Dockerfile') != ''
run: |
docker build -t template-test . --no-cache 2>&1 | tail -5
echo "✓ Docker build succeeded"
- name: Check for secrets
run: |
if grep -rE "(sk-ant-|sk_test_|ghp_|AKIA[A-Z0-9])" --include="*.yaml" --include="*.yml" --include="*.md" --include="*.py" --include="*.sh" . 2>/dev/null; then
echo "::error::Potential secret found in committed files"
exit 1
fi
echo "✓ No secrets detected"

79
README.md Normal file
View File

@ -0,0 +1,79 @@
# molecule-ci
Shared CI workflows for the Molecule AI ecosystem. Every plugin, workspace template, and org template repo calls these reusable workflows to enforce a standard validation gate.
## Usage
### Plugin repos (`molecule-ai-plugin-*`)
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
validate:
uses: Molecule-AI/molecule-ci/.github/workflows/validate-plugin.yml@main
```
### Workspace template repos (`molecule-ai-workspace-template-*`)
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
validate:
uses: Molecule-AI/molecule-ci/.github/workflows/validate-workspace-template.yml@main
```
### Org template repos (`molecule-ai-org-template-*`)
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
validate:
uses: Molecule-AI/molecule-ci/.github/workflows/validate-org-template.yml@main
```
## What each workflow validates
### validate-plugin
| Check | Severity | What it catches |
|---|---|---|
| `plugin.yaml` exists | Error | Missing manifest |
| Required fields (name, version, description) | Error | Incomplete plugin |
| Has content (SKILL.md, hooks/, skills/, or rules/) | Error | Empty plugin |
| SKILL.md starts with heading | Warning | Bad formatting |
| No committed secrets | Error | Leaked API keys |
| No build artifacts | Error | node_modules, __pycache__ |
### validate-workspace-template
| Check | Severity | What it catches |
|---|---|---|
| `config.yaml` exists | Error | Missing config |
| Required fields (name, runtime) | Error | Incomplete template |
| `template_schema_version: 1` | Error | Missing version contract |
| Known runtime check | Warning | Typo in runtime name |
| `adapter.py` imports molecule_runtime | Warning | Legacy imports |
| Dockerfile builds | Error | Broken image |
| molecule-ai-workspace-runtime dependency | Warning | Missing base package |
| No committed secrets | Error | Leaked API keys |
### validate-org-template
| Check | Severity | What it catches |
|---|---|---|
| `org.yaml` exists | Error | Missing org definition |
| Required fields (name) | Error | Incomplete template |
| Workspace structure valid | Error | Malformed hierarchy |
| `files_dir` references exist | Warning | Broken system-prompt paths |
| `template_schema_version` present | Warning | Missing version contract |
| No committed secrets | Error | Leaked API keys |
## License
Business Source License 1.1 — © Molecule AI.