name: Validate Workspace Template on: workflow_call: # Defense-in-depth on the GITHUB_TOKEN scope. This workflow runs # untrusted-by-design code from the calling template repo — pip # installs the template's requirements.txt (post-install hooks), # imports adapter.py, and `docker build`s the Dockerfile (RUN # steps). Each of those primitives can execute arbitrary code with # the token in env. Pinning `contents: read` means the worst a # malicious template PR can do with the token is read public repo # state — no write to issues, no push to branches, no comment-spam, # no workflow re-trigger. # # Fork-PR lockdown (#135): the workflow splits into two jobs: # # validate-static — file-content checks only (secret scan, YAML # parse, AST inspection of adapter.py without # import). Always runs, including external fork # PRs. Safe because no third-party code executes. # # validate-runtime — pip install requirements.txt + import # adapter.py + docker build. SKIPPED on fork # PRs because each step is arbitrary code # execution from the template repo's perspective. # Internal PRs and post-merge runs still get # the full coverage. # # What this prevents: a malicious external PR can no longer # crypto-mine on the runner, DNS-exfiltrate runner metadata, or # attempt to read GitHub-Actions internal env via a setup.py # postinstall hook. They still get static feedback (secret scan # is the most important security check anyway). # # What this does NOT prevent: malicious template metadata that # passes static checks. The runtime job catches those once the PR # merges (or an internal contributor reposts the change), at which # point branch protection on staging/main blocks the merge if # runtime validation fails. permissions: contents: read jobs: validate-static: name: Template validation (static) runs-on: ubuntu-latest timeout-minutes: 5 steps: # Calling template repo (Dockerfile + config.yaml + adapter.py). - uses: actions/checkout@v4 # Canonical validator script lives in molecule-ci, fetched fresh on # every run. The previous setup expected `.molecule-ci/scripts/` to # be vendored INTO each template repo, which drifted across the 8 # template repos as the validator evolved. Single source of truth # eliminates that drift class entirely — every template runs the # same canonical contract check on every CI run. # Direct git-clone — see validate-plugin.yml for the rationale. # Anonymous fetch of public molecule-ci, no actions/checkout idiosyncrasies. - name: Fetch molecule-ci canonical scripts run: git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-ci.git .molecule-ci-canonical - uses: actions/setup-python@v5 with: python-version: "3.11" # Secret scan — the most important check. Always runs. - name: Check for secrets run: | python3 - << 'PYEOF' import os, re, sys from pathlib import Path PATTERNS = [ re.compile(r'''["']sk-ant-[a-zA-Z0-9]{50,}["']'''), re.compile(r'''["']ghp_[a-zA-Z0-9]{36,}["']'''), re.compile(r'''["']AKIA[A-Z0-9]{16}["']'''), re.compile(r'''["'][a-zA-Z0-9/+=]{40}["']'''), re.compile(r'''["']sk_test_[a-zA-Z0-9]{24,}["']'''), re.compile(r'''["']Bearer\s+[a-zA-Z0-9_.-]{20,}["']'''), re.compile(r'''ghp_[a-zA-Z0-9]{36,}'''), re.compile(r'''sk-ant-[a-zA-Z0-9]{50,}'''), ] SKIP_DIRS = {'.molecule-ci', '.git', 'node_modules', '__pycache__'} EXTENSIONS = {'.yaml', '.yml', '.md', '.py', '.sh'} def is_false_positive(line): ctx = line.lower() return '...' in ctx or '&1 | tail -5 && echo "✓ Docker build succeeded" # Aggregator that emits a single `Template validation` check name — # the caller's job (`validate:` in each template's ci.yml) plus this # job's name produces `validate / Template validation`, which is what # template-repo branch protection has historically required. # # Why it's needed: the workflow was refactored from one job into # validate-static + validate-runtime (with matrix-suffixed display # names) for fork-PR security. The matrix names never match the # original required-check name, so PR auto-merge silently hung in # BLOCKED forever on every template repo (caught while shipping # fixes for the boot-smoke gate, openclaw#11 + hermes#29). # # `if: always()` so it reports out even when validate-static fails — # without that, GitHub marks the aggregator as SKIPPED and branch # protection still blocks because the required check never reports # a final state. # # Fork-PR semantics: validate-runtime is intentionally skipped on # fork PRs (security gate). Treat `skipped` as a pass for the # aggregator on forks so static-only coverage doesn't make every # external PR un-mergeable. template-validation: name: Template validation runs-on: ubuntu-latest needs: [validate-static, validate-runtime] if: always() timeout-minutes: 1 steps: - name: Aggregate run: | static="${{ needs.validate-static.result }}" runtime="${{ needs.validate-runtime.result }}" echo "validate-static: $static" echo "validate-runtime: $runtime" if [ "$static" != "success" ]; then echo "::error::validate-static did not succeed: $static" exit 1 fi if [ "$runtime" != "success" ] && [ "$runtime" != "skipped" ]; then echo "::error::validate-runtime did not succeed: $runtime" exit 1 fi echo "::notice::Template validation aggregate passed (static=$static, runtime=$runtime)"