From f035b6e108c195b4c2f6bf6455cf22a4f1b6fccc Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 16 Apr 2026 04:42:16 -0700 Subject: [PATCH] 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` --- .github/workflows/validate-org-template.yml | 137 ++++++++++++++++++ .github/workflows/validate-plugin.yml | 105 ++++++++++++++ .../workflows/validate-workspace-template.yml | 108 ++++++++++++++ README.md | 79 ++++++++++ 4 files changed, 429 insertions(+) create mode 100644 .github/workflows/validate-org-template.yml create mode 100644 .github/workflows/validate-plugin.yml create mode 100644 .github/workflows/validate-workspace-template.yml create mode 100644 README.md diff --git a/.github/workflows/validate-org-template.yml b/.github/workflows/validate-org-template.yml new file mode 100644 index 0000000..ab82b4a --- /dev/null +++ b/.github/workflows/validate-org-template.yml @@ -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", "") + 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" diff --git a/.github/workflows/validate-plugin.yml b/.github/workflows/validate-plugin.yml new file mode 100644 index 0000000..ac01087 --- /dev/null +++ b/.github/workflows/validate-plugin.yml @@ -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" diff --git a/.github/workflows/validate-workspace-template.yml b/.github/workflows/validate-workspace-template.yml new file mode 100644 index 0000000..eb430bc --- /dev/null +++ b/.github/workflows/validate-workspace-template.yml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..03e5d3b --- /dev/null +++ b/README.md @@ -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.