ci: add .molecule-ci/scripts/ directory with all CI scripts
This commit is contained in:
parent
c9344eabeb
commit
487056d7dd
96
.molecule-ci/scripts/check-secrets.py
Normal file
96
.molecule-ci/scripts/check-secrets.py
Normal 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()
|
||||
1
.molecule-ci/scripts/requirements.txt
Normal file
1
.molecule-ci/scripts/requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
pyyaml>=6.0
|
||||
69
.molecule-ci/scripts/validate-org-template.py
Normal file
69
.molecule-ci/scripts/validate-org-template.py
Normal 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)")
|
||||
52
.molecule-ci/scripts/validate-plugin.py
Normal file
52
.molecule-ci/scripts/validate-plugin.py
Normal 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)}")
|
||||
47
.molecule-ci/scripts/validate-workspace-template.py
Normal file
47
.molecule-ci/scripts/validate-workspace-template.py
Normal 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')})")
|
||||
Loading…
Reference in New Issue
Block a user