CI / Template validation (static) (pull_request) Successful in 7s
CI / Template validation (static) (push) Successful in 7s
meta-ci-advisory / meta (advisory) (pull_request) Successful in 8s
verify-providers-projection / Regenerate projection, fail on drift, assert registry ⊆ template (pull_request) Successful in 14s
Secret scan / secret-scan (pull_request) Successful in 17s
CI / Adapter unit tests (pull_request) Successful in 38s
CI / Adapter unit tests (push) Successful in 39s
CI / T4 tier-4 conformance (live) (pull_request) Successful in 1m22s
CI / Template validation (runtime) (push) Successful in 1m25s
CI / Template validation (runtime) (pull_request) Successful in 1m27s
CI / validate (pull_request) Successful in 3s
CI / T4 tier-4 conformance (live) (push) Successful in 1m29s
CI / validate (push) Successful in 4s
Class fix, ported from template-hermes. Both jobs that build this template's
image ran
docker build ... --no-cache 2>&1 | tail -5
A failing buildkit run ends with the offending Dockerfile fragment and a
"failed to solve ... exit code: 1" summary, so those five lines are the same
every time and never contain the cause. On the hermes sibling that turned a
transient into a manual sweep against the live forge just to distinguish a bad
pin from a network blip; the rerun was green, so the only lasting artifact of
the incident was that CI could not explain its own failure. All four workspace
templates carried the identical pipe.
Nothing here changes what CI accepts -- only what it can tell you when it
refuses.
The build is now redirected to a file instead of piped, and the whole log is
printed inside a collapsed ::group:: when it fails. The FULL log, not a bigger
tail: buildkit interleaves parallel stages, so the failing layer's output can
sit arbitrarily far from the end, and a larger constant is the same defect with
a different number. Redirect rather than pipe because `tail` should not be in a
position to own the build's exit status.
Kept INLINE rather than extracted to a helper: tests/test_ci_runtime_image_pin.py
already pins "exactly one `docker build` step per job" as part of the
runtime-image contract, and a first attempt to source a shell helper broke that
guard in all four templates. The guard was right; the refactor was wrong. The
failure-path echoes say "image build" for the same reason -- "docker build" in
an echo would inflate that count.
test_failing_image_build_surfaces_its_own_output covers both jobs and kills
three mutants: restoring the pipe, printing a tail instead of the full log on
failure, and swallowing the build's exit status.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
651 lines
33 KiB
YAML
651 lines
33 KiB
YAML
name: CI
|
|
|
|
# Repository-local validation pipeline. It fetches the canonical validator
|
|
# from molecule-ci at a reviewed immutable commit on each run, while keeping
|
|
# this repository's protected context names and runner requirements explicit.
|
|
#
|
|
# Workflow compatibility choices:
|
|
# - No workflow_dispatch.inputs
|
|
# - No merge_group trigger
|
|
# - No cross-repo uses:
|
|
# - GITHUB_SERVER_URL pinned at workflow level
|
|
# - No on.push.paths: filter
|
|
# - timeout-minutes on every job
|
|
|
|
on: [push, pull_request]
|
|
|
|
# Defense-in-depth de-dup ONLY (the t4-conformance unique-name fix is the
|
|
# actual fail-closed primitive against the shared-host-daemon race; see
|
|
# that job). Scope per workflow + ref + EVENT so the push run and the
|
|
# pull_request run of the same internal-PR commit get DISTINCT groups —
|
|
# they must both complete (each emits its own required-status context;
|
|
# feedback_gitea_gate_check_required_list_not_combined_status). Never
|
|
# per-SHA-global: that silently cross-cancels legit required checks
|
|
# (feedback_concurrency_group_per_sha). cancel-in-progress:false so an
|
|
# in-flight live T4 probe is never aborted mid-assertion (a cancelled
|
|
# privileged probe would look like a gate failure / flake); a newer push
|
|
# to the same ref+event simply queues behind it.
|
|
concurrency:
|
|
group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
GITHUB_SERVER_URL: https://git.moleculesai.app
|
|
# Canonical validation tooling is reviewed and content-addressed.
|
|
MOLECULE_CI_REF: 11b8598e5c0b3f0b1031733a8d5f6bc238f146a4
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
validate-static:
|
|
name: Template validation (static)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
persist-credentials: false
|
|
- name: Fetch immutable molecule-ci canonical scripts
|
|
run: |
|
|
set -euo pipefail
|
|
CI_ROOT=".molecule-ci-canonical"
|
|
mkdir "$CI_ROOT"
|
|
git -C "$CI_ROOT" init -q
|
|
git -C "$CI_ROOT" remote add origin https://git.moleculesai.app/molecule-ai/molecule-ci.git
|
|
fetched=false
|
|
for attempt in 1 2 3; do
|
|
if GIT_ASKPASS=/bin/false GIT_TERMINAL_PROMPT=0 git -c credential.helper= -c http.userAgent=curl/8.4.0 -C "$CI_ROOT" fetch --no-tags --depth 1 origin "$MOLECULE_CI_REF"; then
|
|
fetched=true
|
|
break
|
|
fi
|
|
sleep "$attempt"
|
|
done
|
|
if [ "$fetched" != true ]; then
|
|
echo "::error::immutable molecule-ci validator fetch failed"
|
|
exit 1
|
|
fi
|
|
git -C "$CI_ROOT" checkout -q --detach FETCH_HEAD
|
|
ACTUAL_CI_REF="$(git -C "$CI_ROOT" rev-parse HEAD)"
|
|
if [ "$ACTUAL_CI_REF" != "$MOLECULE_CI_REF" ]; then
|
|
echo "::error::immutable molecule-ci validator ref mismatch"
|
|
exit 1
|
|
fi
|
|
- 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-canonical', '.git', 'node_modules', '__pycache__'}
|
|
EXTENSIONS = {'.yaml', '.yml', '.md', '.py', '.sh'}
|
|
|
|
def is_false_positive(line):
|
|
ctx = line.lower()
|
|
return '...' in ctx or '<example' in ctx or '</example' in ctx
|
|
|
|
root = Path(os.environ.get('GITHUB_WORKSPACE', '.'))
|
|
warnings = []
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
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
|
|
try:
|
|
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
for lineno, line in enumerate(f.readlines(), 1):
|
|
for pattern in PATTERNS:
|
|
for match in pattern.finditer(line):
|
|
if not is_false_positive(line):
|
|
warnings.append(f" {filepath}:{lineno}: {match.group(0)[:40]}...")
|
|
except Exception:
|
|
pass
|
|
|
|
if warnings:
|
|
print("::error::Potential secret found in committed files:")
|
|
for w in warnings:
|
|
print(w)
|
|
sys.exit(1)
|
|
else:
|
|
print("::notice::No secrets detected")
|
|
PYEOF
|
|
- run: pip install pyyaml packaging -q
|
|
- name: Reject legacy declared-plugin installer
|
|
run: |
|
|
python3 - << 'PYEOF'
|
|
from pathlib import Path
|
|
from packaging.requirements import Requirement
|
|
from packaging.version import Version
|
|
|
|
entrypoint = Path("entrypoint.sh").read_text(encoding="utf-8")
|
|
executable_lines = "\n".join(
|
|
line
|
|
for line in entrypoint.splitlines()
|
|
if not line.lstrip().startswith("#")
|
|
)
|
|
forbidden = (
|
|
"MOLECULE_DECLARED_PLUGINS",
|
|
"molecule_runtime.plugin_sources",
|
|
"install_declared_plugins",
|
|
"for _plg_src in $MOLECULE_DECLARED_PLUGINS",
|
|
"/archive/${_plg_ref}.tar.gz",
|
|
"Authorization: token ${MOLECULE_TEMPLATE_REPO_TOKEN}",
|
|
'mkdir -p "/configs/plugins/$_plg_name"',
|
|
'cp -a "$_plg_dir/." "/configs/plugins/$_plg_name/"',
|
|
)
|
|
found = [fragment for fragment in forbidden if fragment in executable_lines]
|
|
if found:
|
|
raise SystemExit(
|
|
"legacy privileged declared-plugin installer detected: "
|
|
+ ", ".join(found)
|
|
)
|
|
|
|
runtime_version = Version(Path(".runtime-version").read_text().strip())
|
|
hardened_version = Version("0.4.0")
|
|
runtime_requirement = next(
|
|
Requirement(line)
|
|
for line in Path("requirements.txt").read_text().splitlines()
|
|
if line.strip().startswith("molecules-workspace-runtime")
|
|
)
|
|
if runtime_version < hardened_version:
|
|
raise SystemExit("runtime pin predates hardened plugin installer")
|
|
if runtime_version not in runtime_requirement.specifier:
|
|
raise SystemExit("runtime pin is outside the template requirement")
|
|
hardened_floor = any(
|
|
specifier.operator in {">=", ">", "==", "===", "~="}
|
|
and Version(specifier.version) >= hardened_version
|
|
for specifier in runtime_requirement.specifier
|
|
)
|
|
if not hardened_floor:
|
|
raise SystemExit("template requirement lacks a hardened runtime floor")
|
|
if Version("0.3.125") in runtime_requirement.specifier:
|
|
raise SystemExit("template requirement permits vulnerable runtime")
|
|
PYEOF
|
|
- run: python3 .molecule-ci-canonical/scripts/validate-workspace-template.py --static-only
|
|
# Audit E1: scripts/molecule-askpass is byte-copied across every workspace
|
|
# template (no shared base image yet). Pin its sha256 so an unintentional
|
|
# edit — or a hand-copy that drifts from its peers — reds this PR. All
|
|
# templates pin the SAME value; a legitimate change must update the file
|
|
# AND this pin in every template together (which a reviewer sees). Always
|
|
# fires (validate-static has no paths filter / fork guard) and feeds the
|
|
# required `validate` aggregate, so it is a hard gate with no secrets.
|
|
- name: askpass helper drift guard (audit E1)
|
|
run: |
|
|
expected=7def3e86a3edf8ff28018081f14a6e390790cb102fd06c27c3f7040b07464303
|
|
test -f scripts/molecule-askpass || { echo "::error::scripts/molecule-askpass is missing"; exit 1; }
|
|
actual=$(sha256sum scripts/molecule-askpass | cut -d' ' -f1)
|
|
if [ "$actual" != "$expected" ]; then
|
|
echo "::error::scripts/molecule-askpass drifted from the pinned cross-template sha256."
|
|
echo " expected $expected"
|
|
echo " actual $actual"
|
|
echo " If this change is intentional, apply it identically to EVERY workspace template and bump this pin in each template's ci.yml."
|
|
exit 1
|
|
fi
|
|
echo "askpass pin OK ($actual)"
|
|
|
|
# pc2-safe: false — needs docker for Docker build smoke; routes to docker-host only
|
|
# (task #390 B-lite — PC2 native runners advertise `ubuntu-latest` but cannot
|
|
# docker due to npipe→Linux-container bridging gap; AND-match on both labels
|
|
# excludes them while keeping the Linux docker-host runners eligible.)
|
|
validate-runtime:
|
|
name: Template validation (runtime)
|
|
runs-on: [ubuntu-latest, docker-host]
|
|
timeout-minutes: 15
|
|
needs: validate-static
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
persist-credentials: false
|
|
- name: Skip executable validation for external forks
|
|
if: github.event.pull_request.head.repo.fork == true
|
|
run: echo "::notice::runtime installation, imports, tests, and Docker builds are disabled for external forks"
|
|
- name: Fetch immutable molecule-ci canonical scripts
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: |
|
|
set -euo pipefail
|
|
CI_ROOT=".molecule-ci-canonical"
|
|
mkdir "$CI_ROOT"
|
|
git -C "$CI_ROOT" init -q
|
|
git -C "$CI_ROOT" remote add origin https://git.moleculesai.app/molecule-ai/molecule-ci.git
|
|
fetched=false
|
|
for attempt in 1 2 3; do
|
|
if GIT_ASKPASS=/bin/false GIT_TERMINAL_PROMPT=0 git -c credential.helper= -c http.userAgent=curl/8.4.0 -C "$CI_ROOT" fetch --no-tags --depth 1 origin "$MOLECULE_CI_REF"; then
|
|
fetched=true
|
|
break
|
|
fi
|
|
sleep "$attempt"
|
|
done
|
|
if [ "$fetched" != true ]; then
|
|
echo "::error::immutable molecule-ci validator fetch failed"
|
|
exit 1
|
|
fi
|
|
git -C "$CI_ROOT" checkout -q --detach FETCH_HEAD
|
|
ACTUAL_CI_REF="$(git -C "$CI_ROOT" rev-parse HEAD)"
|
|
if [ "$ACTUAL_CI_REF" != "$MOLECULE_CI_REF" ]; then
|
|
echo "::error::immutable molecule-ci validator ref mismatch"
|
|
exit 1
|
|
fi
|
|
- name: Install canonical validator dependencies
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: python3 -m pip install --isolated --break-system-packages -q pyyaml jsonschema packaging
|
|
- name: Install source-pinned runtime and template dependencies
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: python3 .molecule-ci-canonical/scripts/install_workspace_dependencies.py --allow-missing --break-system-packages
|
|
- name: Validate workspace-template contract (full)
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: python3 .molecule-ci-canonical/scripts/validate-workspace-template.py
|
|
- name: Adapter platform-routing unit tests
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
# Targeted early feedback for platform routing. The repository-tests
|
|
# job below installs the full test dependencies and runs all of tests/.
|
|
run: |
|
|
pip install -q pytest
|
|
python3 -m pytest tests/test_platform_routing.py -q
|
|
- name: Docker build smoke test
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: |
|
|
set -euo pipefail
|
|
: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}"
|
|
: "${GITHUB_RUN_ATTEMPT:?GITHUB_RUN_ATTEMPT is required}"
|
|
SMOKE_TAG="molecule-ai-workspace-openclaw-smoke-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
|
trap 'docker image rm -f "$SMOKE_TAG" >/dev/null 2>&1 || true' EXIT
|
|
if ! docker info >/dev/null 2>&1; then
|
|
echo "::warning::docker daemon unreachable — skipping Docker build smoke"
|
|
exit 0
|
|
fi
|
|
if [ ! -s .runtime-version ]; then
|
|
echo "::error::.runtime-version is missing or empty"
|
|
exit 1
|
|
fi
|
|
EXPECTED_RUNTIME_VERSION="$(tr -d '[:space:]' < .runtime-version)"
|
|
# Redirect, do not pipe: `tail` must not be able to truncate the cause
|
|
# or own the exit status. On failure the WHOLE log is printed -- a fixed
|
|
# window is what made template-hermes#342 undiagnosable, and buildkit
|
|
# interleaves parallel stages so the failing layer can sit far from the end.
|
|
BUILD_LOG="$(mktemp)"
|
|
rc=0
|
|
docker build --build-arg RUNTIME_VERSION="$EXPECTED_RUNTIME_VERSION" -t "$SMOKE_TAG" . --no-cache >"$BUILD_LOG" 2>&1 || rc=$?
|
|
if [ "$rc" -ne 0 ]; then
|
|
echo "::group::image build output ($(wc -l <"$BUILD_LOG") lines, build failed)"
|
|
cat "$BUILD_LOG"
|
|
echo "::endgroup::"
|
|
echo "::error::image build failed (exit $rc)"
|
|
exit "$rc"
|
|
fi
|
|
tail -5 "$BUILD_LOG"
|
|
ACTUAL_RUNTIME_VERSION="$(
|
|
docker run --rm --entrypoint python3 "$SMOKE_TAG" \
|
|
-c 'from importlib.metadata import version; print(version("molecules-workspace-runtime"))'
|
|
)"
|
|
if [ "$ACTUAL_RUNTIME_VERSION" != "$EXPECTED_RUNTIME_VERSION" ]; then
|
|
echo "::error::image runtime $ACTUAL_RUNTIME_VERSION does not match .runtime-version $EXPECTED_RUNTIME_VERSION"
|
|
exit 1
|
|
fi
|
|
echo "Docker build succeeded with exact runtime $ACTUAL_RUNTIME_VERSION"
|
|
|
|
# --- Layer-3: real T4 tier-4 conformance gate (RFC internal#456 §11) ---
|
|
# NOT a string-match. Builds the actual image, runs it under the EXACT
|
|
# flags the controlplane provisioner emits for tier-4
|
|
# (userdata_containerized.go @ec2384c: --privileged --pid=host
|
|
# -v /:/host -v /var/run/docker.sock:/var/run/docker.sock), then
|
|
# asserts BOTH properties on the RUNNING container, atomically
|
|
# (RFC §10 — either failing fails the build):
|
|
# (a) the uid-1000 agent can attain host root
|
|
# (sudo nsenter --target 1 --mount --pid -- id -u == 0)
|
|
# (b) /configs/.auth_token is owned by uid 1000 (so the list_peers
|
|
# bearer token stays readable after the root->uid-1000 drop —
|
|
# this is the OpenClaw "list_peers works by accident (root==root)"
|
|
# regression class; the entrypoint chown is the fix).
|
|
# The flags are not hard-coded blind: they are the documented
|
|
# provisioner contract; drift is caught because the controlplane
|
|
# string-match unit test guards the emission side and this gate
|
|
# guards the runtime side.
|
|
# pc2-safe: false — hard-gate docker (fails closed if daemon unreachable);
|
|
# routes to docker-host only (task #390 B-lite).
|
|
t4-conformance:
|
|
name: T4 tier-4 conformance (live)
|
|
runs-on: [ubuntu-latest, docker-host]
|
|
timeout-minutes: 15
|
|
needs: validate-static
|
|
# Runs after static validation. Avoid job-level if: guards here so the
|
|
# protected context always reaches a terminal state.
|
|
env:
|
|
MOLECULE_CI_REF: 11b8598e5c0b3f0b1031733a8d5f6bc238f146a4
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
persist-credentials: false
|
|
- name: Skip privileged conformance for external forks
|
|
if: github.event.pull_request.head.repo.fork == true
|
|
run: echo "::notice::privileged T4 validation is disabled for external forks"
|
|
- name: Prepare immutable management-MCP attestation
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: |
|
|
set -euo pipefail
|
|
: "${RUNNER_TEMP:?RUNNER_TEMP is required}"
|
|
RUN_SUFFIX="${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
|
|
MOLECULE_CI_ROOT="$RUNNER_TEMP/molecule-ci-mcp-e2e-$RUN_SUFFIX"
|
|
MCP_ATTESTATION="$RUNNER_TEMP/mcp-lockstep-$RUN_SUFFIX.json"
|
|
MCP_ATTESTATION_TMP="${MCP_ATTESTATION}.tmp"
|
|
MCP_ATTESTATION_SHA256="${MCP_ATTESTATION}.sha256"
|
|
RUNTIME_VERSION_FILE="$RUNNER_TEMP/mcp-runtime-version-$RUN_SUFFIX"
|
|
mkdir "$MOLECULE_CI_ROOT"
|
|
git -C "$MOLECULE_CI_ROOT" init -q
|
|
git -C "$MOLECULE_CI_ROOT" remote add origin \
|
|
https://git.moleculesai.app/molecule-ai/molecule-ci.git
|
|
fetched=false
|
|
for attempt in 1 2 3; do
|
|
if GIT_ASKPASS=/bin/false GIT_TERMINAL_PROMPT=0 \
|
|
git -c credential.helper= -c http.userAgent=curl/8.4.0 \
|
|
-C "$MOLECULE_CI_ROOT" \
|
|
fetch --no-tags --depth 1 origin "$MOLECULE_CI_REF"; then
|
|
fetched=true
|
|
break
|
|
fi
|
|
sleep "$attempt"
|
|
done
|
|
if [ "$fetched" != true ]; then
|
|
echo "::error::immutable molecule-ci verifier fetch failed"
|
|
exit 1
|
|
fi
|
|
git -C "$MOLECULE_CI_ROOT" checkout -q --detach FETCH_HEAD
|
|
ACTUAL_CI_REF="$(git -C "$MOLECULE_CI_ROOT" rev-parse HEAD)"
|
|
if [ "$ACTUAL_CI_REF" != "$MOLECULE_CI_REF" ]; then
|
|
echo "::error::immutable molecule-ci verifier ref mismatch"
|
|
exit 1
|
|
fi
|
|
git -C "$MOLECULE_CI_ROOT" diff --quiet --no-ext-diff --no-textconv "$MOLECULE_CI_REF" -- scripts/mcp_pin_lockstep.py scripts/mcp_built_image_e2e.py
|
|
python3 "$MOLECULE_CI_ROOT/scripts/mcp_pin_lockstep.py" \
|
|
--repo-root . --json > "$MCP_ATTESTATION_TMP"
|
|
mv "$MCP_ATTESTATION_TMP" "$MCP_ATTESTATION"
|
|
sha256sum "$MCP_ATTESTATION" > "$MCP_ATTESTATION_SHA256"
|
|
git -C "$MOLECULE_CI_ROOT" diff --quiet --no-ext-diff --no-textconv "$MOLECULE_CI_REF" -- scripts/mcp_pin_lockstep.py scripts/mcp_built_image_e2e.py
|
|
sha256sum --check "$MCP_ATTESTATION_SHA256"
|
|
EXPECTED_RUNTIME_VERSION="$(python3 - "$MOLECULE_CI_ROOT" "$MCP_ATTESTATION" <<'PY'
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(sys.argv[1]) / "scripts"))
|
|
from mcp_built_image_e2e import load_attestation
|
|
|
|
with Path(sys.argv[2]).open("rb") as stream:
|
|
sys.stdout.write(load_attestation(stream).runtime_version)
|
|
PY
|
|
)"
|
|
printf '%s\n' "$EXPECTED_RUNTIME_VERSION" > "$RUNTIME_VERSION_FILE"
|
|
- name: Build the runtime image
|
|
id: build
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: |
|
|
set -euo pipefail
|
|
: "${RUNNER_TEMP:?RUNNER_TEMP is required}"
|
|
RUN_SUFFIX="${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
|
|
MOLECULE_CI_ROOT="$RUNNER_TEMP/molecule-ci-mcp-e2e-$RUN_SUFFIX"
|
|
MCP_ATTESTATION="$RUNNER_TEMP/mcp-lockstep-$RUN_SUFFIX.json"
|
|
MCP_ATTESTATION_SHA256="${MCP_ATTESTATION}.sha256"
|
|
RUNTIME_VERSION_FILE="$RUNNER_TEMP/mcp-runtime-version-$RUN_SUFFIX"
|
|
T4_TAG="t4-conformance-test:${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
|
|
MCP_VERIFY_LOG="$RUNNER_TEMP/mcp-built-image-e2e-$RUN_SUFFIX.log"
|
|
MCP_VERIFY_CONTAINER="mcp-built-image-e2e-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
|
|
KEEP_T4_IMAGE=0
|
|
cleanup_t4_build() {
|
|
docker rm -f "$MCP_VERIFY_CONTAINER" >/dev/null 2>&1 || true
|
|
rm -rf -- "$MOLECULE_CI_ROOT"
|
|
rm -f -- "$MCP_ATTESTATION" "$MCP_ATTESTATION_SHA256" "$RUNTIME_VERSION_FILE" "$MCP_VERIFY_LOG"
|
|
if [ "$KEEP_T4_IMAGE" -ne 1 ]; then
|
|
docker image rm -f "$T4_TAG" >/dev/null 2>&1 || true
|
|
fi
|
|
}
|
|
trap cleanup_t4_build EXIT
|
|
if ! docker info >/dev/null 2>&1; then
|
|
echo "::error::docker daemon unreachable — T4 conformance gate CANNOT verify host-root reach. This is a hard gate; failing closed (do NOT treat as skip). Fix runner-config (internal#222) to unblock."
|
|
exit 1
|
|
fi
|
|
if [ ! -s "$MCP_ATTESTATION" ] || [ ! -s "$RUNTIME_VERSION_FILE" ]; then
|
|
echo "::error::immutable management-MCP attestation is missing"
|
|
exit 1
|
|
fi
|
|
if [ ! -f "$MOLECULE_CI_ROOT/scripts/mcp_built_image_e2e.py" ]; then
|
|
echo "::error::immutable final-image verifier is missing"
|
|
exit 1
|
|
fi
|
|
EXPECTED_RUNTIME_VERSION="$(<"$RUNTIME_VERSION_FILE")"
|
|
DECLARED_RUNTIME_VERSION="$(tr -d '[:space:]' < .runtime-version)"
|
|
if [ "$EXPECTED_RUNTIME_VERSION" != "$DECLARED_RUNTIME_VERSION" ]; then
|
|
echo "::error::attested runtime version disagrees with .runtime-version"
|
|
exit 1
|
|
fi
|
|
# Redirect, do not pipe: `tail` must not be able to truncate the cause
|
|
# or own the exit status. On failure the WHOLE log is printed -- a fixed
|
|
# window is what made template-hermes#342 undiagnosable, and buildkit
|
|
# interleaves parallel stages so the failing layer can sit far from the end.
|
|
BUILD_LOG="$(mktemp)"
|
|
rc=0
|
|
docker build --build-arg RUNTIME_VERSION="$EXPECTED_RUNTIME_VERSION" -t "$T4_TAG" . --no-cache >"$BUILD_LOG" 2>&1 || rc=$?
|
|
if [ "$rc" -ne 0 ]; then
|
|
echo "::group::image build output ($(wc -l <"$BUILD_LOG") lines, build failed)"
|
|
cat "$BUILD_LOG"
|
|
echo "::endgroup::"
|
|
echo "::error::image build failed (exit $rc)"
|
|
exit "$rc"
|
|
fi
|
|
tail -5 "$BUILD_LOG"
|
|
ACTUAL_RUNTIME_VERSION="$(
|
|
docker run --rm --entrypoint python3 "$T4_TAG" \
|
|
-c 'from importlib.metadata import version; print(version("molecules-workspace-runtime"))'
|
|
)"
|
|
if [ "$ACTUAL_RUNTIME_VERSION" != "$EXPECTED_RUNTIME_VERSION" ]; then
|
|
echo "::error::T4 image runtime $ACTUAL_RUNTIME_VERSION does not match .runtime-version $EXPECTED_RUNTIME_VERSION"
|
|
exit 1
|
|
fi
|
|
rm -f "$MCP_VERIFY_LOG"
|
|
docker rm -f "$MCP_VERIFY_CONTAINER" >/dev/null 2>&1 || true
|
|
docker create --interactive --name "$MCP_VERIFY_CONTAINER" \
|
|
--network none \
|
|
--user 1000:1000 --workdir /tmp \
|
|
--cap-drop ALL --security-opt no-new-privileges \
|
|
--pids-limit 128 --memory 768m --cpus 1 \
|
|
--tmpfs /tmp:size=64m \
|
|
--entrypoint python3 "$T4_TAG" \
|
|
/mcp_built_image_e2e.py >/dev/null
|
|
git -C "$MOLECULE_CI_ROOT" diff --quiet --no-ext-diff --no-textconv "$MOLECULE_CI_REF" -- scripts/mcp_pin_lockstep.py scripts/mcp_built_image_e2e.py
|
|
docker cp "$MOLECULE_CI_ROOT/scripts/mcp_built_image_e2e.py" \
|
|
"$MCP_VERIFY_CONTAINER:/mcp_built_image_e2e.py"
|
|
sha256sum --check "$MCP_ATTESTATION_SHA256"
|
|
docker start --attach --interactive "$MCP_VERIFY_CONTAINER" \
|
|
< "$MCP_ATTESTATION" | tee "$MCP_VERIFY_LOG"
|
|
docker rm "$MCP_VERIFY_CONTAINER" >/dev/null
|
|
if ! grep -qxF 'mcp-built-image-e2e:sentinel:executed' "$MCP_VERIFY_LOG"; then
|
|
echo "::error::final-image management-MCP verifier sentinel is missing"
|
|
exit 1
|
|
fi
|
|
KEEP_T4_IMAGE=1
|
|
- name: Run under EXACT tier-4 provisioner flags + assert host-root reach AND token agent-ownership
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
env:
|
|
T4_PROBE_NAME: "t4probe-${{ github.run_id }}-${{ github.run_attempt }}"
|
|
MOLECULE_T4_PROBE_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
|
|
run: |
|
|
set -euo pipefail
|
|
T4_TAG="t4-conformance-test:${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
|
|
T4_PROBE="$T4_PROBE_NAME"
|
|
# EXACT flags from controlplane userdata_containerized.go
|
|
# (tier-4 emission @ec2384c). The molecule-runtime entrypoint
|
|
# wants a live workspace; we only need the container up long
|
|
# enough to probe, so override the command with a sleep and
|
|
# exercise the agent context directly.
|
|
trap 'docker rm -f "$T4_PROBE" >/dev/null 2>&1 || true; docker rmi -f "$T4_TAG" >/dev/null 2>&1 || true' EXIT
|
|
docker rm -f "$T4_PROBE" >/dev/null 2>&1 || true
|
|
docker run -d \
|
|
--name "$T4_PROBE" \
|
|
--network host \
|
|
--privileged \
|
|
--pid=host \
|
|
-v /:/host \
|
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
--entrypoint /bin/sh \
|
|
"$T4_TAG" -c 'sleep 600' >/dev/null
|
|
|
|
echo "=== Reproduce the agent-owned-token half of the entrypoint contract ==="
|
|
# The real entrypoint chowns /configs to agent before gosu;
|
|
# /configs is an unmounted VOLUME in this probe, so reproduce
|
|
# the exact contract step the entrypoint performs, then assert.
|
|
docker exec "$T4_PROBE" sh -c 'mkdir -p /configs && touch /configs/.auth_token && chown -R agent:agent /configs'
|
|
|
|
echo "=== (b) token agent-ownership: stat /configs/.auth_token ==="
|
|
OWNER_UID=$(docker exec "$T4_PROBE" stat -c '%u' /configs/.auth_token)
|
|
echo "owner_uid=$OWNER_UID"
|
|
if [ "$OWNER_UID" != "1000" ]; then
|
|
echo "::error::T4 contract violated: /configs/.auth_token owner_uid=$OWNER_UID (expected 1000). Escalation leg must NOT regress agent-readable token (RFC internal#456 §10, OpenClaw list_peers root==root accident / Hermes list_peers-401 class)."
|
|
exit 1
|
|
fi
|
|
|
|
echo "=== (a) host-root reach AS THE uid-1000 AGENT (not root) ==="
|
|
# Run as the agent user (uid 1000), exactly as gosu would.
|
|
AGENT_HOSTROOT_UID=$(docker exec -u agent "$T4_PROBE" sudo -n nsenter --target 1 --mount --pid -- id -u)
|
|
echo "agent->host-root id -u = $AGENT_HOSTROOT_UID"
|
|
if [ "$AGENT_HOSTROOT_UID" != "0" ]; then
|
|
echo "::error::T4 contract violated: uid-1000 agent could NOT attain host root via 'sudo nsenter --target 1' (got uid=$AGENT_HOSTROOT_UID). T4 escalation leg ABSENT/broken."
|
|
exit 1
|
|
fi
|
|
# Defense-in-depth: host-filesystem write+readback through /host
|
|
# from the agent, proving real host reach (not just a namespace
|
|
# trick on an isolated PID 1).
|
|
MARKER="t4-conformance-$(date +%s)-$RANDOM"
|
|
PROBE_FILE="/host/tmp/.t4-conformance-probe-${MOLECULE_T4_PROBE_ID}"
|
|
docker exec -u agent "$T4_PROBE" sudo -n sh -c "echo $MARKER > $PROBE_FILE"
|
|
READBACK=$(docker exec -u agent "$T4_PROBE" sudo -n cat "$PROBE_FILE")
|
|
docker exec -u agent "$T4_PROBE" sudo -n rm -f "$PROBE_FILE"
|
|
if [ "$READBACK" != "$MARKER" ]; then
|
|
echo "::error::T4 host-fs write+readback through /host failed (got '$READBACK' expected '$MARKER')."
|
|
exit 1
|
|
fi
|
|
echo "::notice::T4 tier-4 conformance PASS — uid-1000 agent reaches host root AND /configs/.auth_token is agent-owned (both, atomically)."
|
|
|
|
tests:
|
|
name: Adapter unit tests
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
persist-credentials: false
|
|
- name: Skip executable tests for external forks
|
|
if: github.event.pull_request.head.repo.fork == true
|
|
run: echo "::notice::dependency installation and repository tests are disabled for external forks"
|
|
- name: Fetch immutable molecule-ci canonical scripts
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: |
|
|
set -euo pipefail
|
|
CI_ROOT=".molecule-ci-canonical"
|
|
mkdir "$CI_ROOT"
|
|
git -C "$CI_ROOT" init -q
|
|
git -C "$CI_ROOT" remote add origin https://git.moleculesai.app/molecule-ai/molecule-ci.git
|
|
fetched=false
|
|
for attempt in 1 2 3; do
|
|
if GIT_ASKPASS=/bin/false GIT_TERMINAL_PROMPT=0 git -c credential.helper= -c http.userAgent=curl/8.4.0 -C "$CI_ROOT" fetch --no-tags --depth 1 origin "$MOLECULE_CI_REF"; then
|
|
fetched=true
|
|
break
|
|
fi
|
|
sleep "$attempt"
|
|
done
|
|
if [ "$fetched" != true ]; then
|
|
echo "::error::immutable molecule-ci validator fetch failed"
|
|
exit 1
|
|
fi
|
|
git -C "$CI_ROOT" checkout -q --detach FETCH_HEAD
|
|
ACTUAL_CI_REF="$(git -C "$CI_ROOT" rev-parse HEAD)"
|
|
if [ "$ACTUAL_CI_REF" != "$MOLECULE_CI_REF" ]; then
|
|
echo "::error::immutable molecule-ci validator ref mismatch"
|
|
exit 1
|
|
fi
|
|
# molecule-ai-sdk (molecule_plugin) is the ADR-004 conformance-suite home
|
|
# (tests/test_conformance.py inherits AdapterConformance FROM the SDK), so
|
|
# it is a test-time dependency alongside the runtime engine. Public test
|
|
# tooling is resolved separately, the runtime comes only through the
|
|
# canonical private-source installer, and the SDK is content-addressed to
|
|
# the reviewed SSOT commit rather than selected from a package index.
|
|
- name: Install public test tooling
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: python3 -m pip install --isolated --break-system-packages -q pytest pytest-asyncio pyyaml python-multipart jsonschema packaging
|
|
- name: Install source-pinned runtime and template dependencies
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: python3 .molecule-ci-canonical/scripts/install_workspace_dependencies.py --allow-missing --break-system-packages
|
|
- name: Install SDK SSOT at reviewed commit
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: python3 -m pip install --isolated --break-system-packages -q "molecule-ai-sdk @ git+https://git.moleculesai.app/molecule-ai/molecule-ai-sdk.git@da42c7f2dae122aaa6f34a74c13e598a87870586"
|
|
# Concierge MCP loaded+callable e2e (fail-before/pass-after) — the
|
|
# strengthened guard for the mcpServers:NONE / declared-but-dead class of
|
|
# regression the mocked test_openclaw_mgmt_mcp.py could not catch. The
|
|
# LOGIC layer stands up a REAL stdio MCP server and asserts the required
|
|
# privileged tool set (provision_workspace, commit_memory, ...) is actually
|
|
# advertised over the wire. The LIVE layer (docker exec into a real
|
|
# concierge) auto-skips here and is driven by the deploy gate via
|
|
# MOLECULE_LIVE_CONCIERGE_CONTAINER.
|
|
- name: Concierge MCP end-to-end tests
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: python3 -m pytest tests/test_openclaw_concierge_mcp_live_e2e.py -v
|
|
- name: Repository unit tests
|
|
if: github.event.pull_request.head.repo.fork != true
|
|
run: python3 -m pytest tests/ -v
|
|
|
|
# GUARD D (per-template pin<->bake lockstep) REMOVED: the mcp-server bake is now
|
|
# the base-runtime SSOT helper (molecule_runtime/scripts/prebake-mgmt-mcp.sh) and
|
|
# the pin lives ONCE in the runtime, lockstep-tested against the SDK contract
|
|
# (test_mcp_plugin_delivery_contract.py). This template carries no ARG/bake to
|
|
# drift, so the per-template gate is obsolete (#54).
|
|
validate:
|
|
name: validate
|
|
runs-on: ubuntu-latest
|
|
needs: [validate-static, validate-runtime, t4-conformance, tests]
|
|
if: always()
|
|
timeout-minutes: 1
|
|
steps:
|
|
- name: Aggregate
|
|
run: |
|
|
static="${{ needs.validate-static.result }}"
|
|
runtime="${{ needs.validate-runtime.result }}"
|
|
t4="${{ needs.t4-conformance.result }}"
|
|
tests="${{ needs.tests.result }}"
|
|
echo "validate-static: $static"
|
|
echo "validate-runtime: $runtime"
|
|
echo "t4-conformance: $t4"
|
|
echo "tests: $tests"
|
|
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
|
|
# T4 conformance is a HARD gate on internal (non-fork) PRs
|
|
# and main pushes. `skipped` is only acceptable on fork PRs.
|
|
# Any other non-success fails the build: "verified" T4 requires
|
|
# this live gate green, never inference.
|
|
is_fork_pr="${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}"
|
|
if [ "$t4" != "success" ]; then
|
|
if [ "$t4" = "skipped" ] && [ "$is_fork_pr" = "true" ]; then
|
|
echo "::notice::t4-conformance skipped on fork PR — allowing aggregate to pass."
|
|
else
|
|
echo "::error::t4-conformance did not succeed: $t4 — T4 host-root reach / token-ownership not verified on a live container. Failing closed (RFC internal#456 §11)."
|
|
exit 1
|
|
fi
|
|
fi
|
|
if [ "$tests" != "success" ]; then
|
|
echo "::error::tests did not succeed: $tests"
|
|
exit 1
|
|
fi
|
|
echo "::notice::Template validation aggregate passed (static=$static, runtime=$runtime, t4=$t4, tests=$tests)"
|