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:
commit
f035b6e108
137
.github/workflows/validate-org-template.yml
vendored
Normal file
137
.github/workflows/validate-org-template.yml
vendored
Normal 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
105
.github/workflows/validate-plugin.yml
vendored
Normal 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"
|
||||
108
.github/workflows/validate-workspace-template.yml
vendored
Normal file
108
.github/workflows/validate-workspace-template.yml
vendored
Normal 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
79
README.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user