ci: add .molecule-ci/scripts/ directory with all CI scripts

This commit is contained in:
Molecule AI · plugin-dev 2026-04-21 11:09:16 +00:00
parent c9344eabeb
commit 487056d7dd
5 changed files with 265 additions and 0 deletions

View File

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Check for leaked credentials in the repo.
Uses context-aware matching to avoid false positives in documentation/examples.
"""
import os
import re
import sys
from pathlib import Path
# Patterns that match real credentials but also common doc examples.
# We match the full assignment/value context to distinguish real from example.
PATTERNS = [
# sk-ant- in quoted export or assignment context (real key: 64 hex chars)
re.compile(r'''["']sk-ant-[a-zA-Z0-9]{50,}["']'''),
# ghp_ GitHub token (37+ chars after prefix)
re.compile(r'''["']ghp_[a-zA-Z0-9]{36,}["']'''),
# AWS access key IDs
re.compile(r'''["']AKIA[A-Z0-9]{16}["']'''),
# AWS secret access keys (40-char)
re.compile(r'''["'][a-zA-Z0-9/+=]{40}["']'''),
# Stripe test keys
re.compile(r'''["']sk_test_[a-zA-Z0-9]{24,}["']'''),
# Generic Bearer tokens
re.compile(r'''["']Bearer\s+[a-zA-Z0-9_.-]{20,}["']'''),
# Generic PAT tokens (ghp_)
re.compile(r'''ghp_[a-zA-Z0-9]{36,}'''),
# Generic sk-ant- (standalone, non-dotted, real length)
re.compile(r'''sk-ant-[a-zA-Z0-9]{50,}'''),
]
# Extensions to scan
EXTENSIONS = {'.yaml', '.yml', '.md', '.py', '.sh'}
# Directories to skip entirely
SKIP_DIRS = {'.molecule-ci', '.git', 'node_modules', '__pycache__'}
def is_false_positive(line: str, match: str) -> bool:
"""Heuristic: lines with ... or <example> or # comment-only are docs examples."""
# If the match is followed by "..." or surrounded by "<" ">" it's an example
ctx = line.lower()
if '...' in ctx:
return True
if '<example' in ctx or '</example' in ctx:
return True
if '#' in line and line.strip().startswith('#'):
# Pure comment line — likely a doc example
return True
return False
def check_file(path: Path) -> list[str]:
"""Return list of warnings for this file. Empty = clean."""
warnings = []
try:
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
except Exception:
return warnings
for lineno, line in enumerate(lines, 1):
for pattern in PATTERNS:
for match in pattern.finditer(line):
if not is_false_positive(line, match.group(0)):
warnings.append(
f" {path}:{lineno}: {match.group(0)[:40]}..."
)
return warnings
def main():
root = Path(os.environ.get('GITHUB_WORKSPACE', '.'))
all_warnings = []
for dirpath, dirnames, filenames in os.walk(root):
# Prune skipped dirs in-place
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for filename in filenames:
if Path(filename).suffix not in EXTENSIONS:
continue
filepath = Path(dirpath) / filename
all_warnings.extend(check_file(filepath))
if all_warnings:
print("::error::Potential secret found in committed files:")
for w in all_warnings:
print(f" {w}")
sys.exit(1)
else:
print("::notice::No secrets detected")
if __name__ == '__main__':
main()

View File

@ -0,0 +1 @@
pyyaml>=6.0

View File

@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Validate a Molecule AI org template repo."""
import os, sys, yaml
# Support !include and other custom YAML tags used by org templates.
# These resolve at platform load time, not at validation time — we just
# need to parse past them without crashing.
class PermissiveLoader(yaml.SafeLoader):
pass
def _generic_constructor(loader, tag_suffix, node):
if isinstance(node, yaml.MappingNode):
return loader.construct_mapping(node)
if isinstance(node, yaml.SequenceNode):
return loader.construct_sequence(node)
return loader.construct_scalar(node)
PermissiveLoader.add_multi_constructor("!", _generic_constructor)
errors = []
if not os.path.isfile("org.yaml"):
print("::error::org.yaml not found at repo root")
sys.exit(1)
with open("org.yaml") as f:
org = yaml.load(f, Loader=PermissiveLoader)
if not org.get("name"):
errors.append("Missing required field: name")
if not org.get("workspaces") and not org.get("defaults"):
errors.append("org.yaml must have at least 'workspaces' or 'defaults'")
def validate_workspace(ws, path=""):
# !include tags resolve to strings at parse time; skip non-dicts
if not isinstance(ws, dict):
return []
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'")
plugins = ws.get("plugins", [])
if plugins and not isinstance(plugins, list):
ws_errors.append(f"{full}: 'plugins' must be a list")
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)
def count_ws(nodes):
c = 0
for n in nodes:
if not isinstance(n, dict):
continue
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)")

View File

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Validate a Molecule AI plugin repo."""
import os, sys, yaml
errors = []
# 1. plugin.yaml exists
if not os.path.isfile("plugin.yaml"):
print("::error::plugin.yaml not found at repo root")
sys.exit(1)
with open("plugin.yaml") as f:
plugin = yaml.safe_load(f)
# 2. Required fields
for field in ["name", "version", "description"]:
if not plugin.get(field):
errors.append(f"Missing required field: {field}")
# 3. Version format
v = str(plugin.get("version", ""))
if v and not all(c in "0123456789." for c in v):
errors.append(f"Invalid version format: {v}")
# 4. Runtimes type
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__}")
# 5. Has content
content_paths = ["SKILL.md", "hooks", "skills", "rules"]
found = [p for p in content_paths if os.path.exists(p)]
if not found:
errors.append("Plugin must contain at least one of: SKILL.md, hooks/, skills/, rules/")
# 6. SKILL.md formatting check
if os.path.isfile("SKILL.md"):
with open("SKILL.md") as f:
first_line = f.readline().strip()
if first_line and not first_line.startswith("#"):
print("::warning::SKILL.md should start with a markdown heading (e.g., # Plugin 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 found:
print(f" Content: {', '.join(found)}")
if runtimes:
print(f" Runtimes: {', '.join(runtimes)}")

View File

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Validate a Molecule AI workspace template repo."""
import os, sys, yaml
errors = []
if not os.path.isfile("config.yaml"):
print("::error::config.yaml not found at repo root")
sys.exit(1)
with open("config.yaml") as f:
config = yaml.safe_load(f)
if not config.get("name"):
errors.append("Missing required field: name")
if not config.get("runtime"):
errors.append("Missing required field: runtime")
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. OK for custom runtimes.")
# Check for legacy imports
if os.path.isfile("adapter.py"):
with open("adapter.py") as f:
content = f.read()
if "molecule_runtime" in content:
print("::warning::adapter.py imports 'molecule_runtime' — legacy import, use 'molecule_ai' or platform SDK")
# Check for missing molecule-ai-workspace-runtime dependency hint
if os.path.isfile("Dockerfile"):
with open("Dockerfile") as f:
content = f.read()
if "molecule-ai-workspace-runtime" not in content:
print("::warning::Dockerfile does not reference 'molecule-ai-workspace-runtime' — may need base runtime package")
sv = config.get("template_schema_version")
if sv is None:
errors.append("Missing template_schema_version (add: template_schema_version: 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')})")