Skip to content

fix(mcp): drop static docker --name so MCP servers survive host recon… #727

fix(mcp): drop static docker --name so MCP servers survive host recon…

fix(mcp): drop static docker --name so MCP servers survive host recon… #727

Workflow file for this run

name: Roster Health
# Runs nightly and on changes to roster/index.yaml to verify every
# Eidolon in the roster is EIIS-conformant and reachable.
#
# Triggers (note: pull_request is intentionally omitted — same-org PR
# branches push to this repo directly and already fire the `push` trigger
# with the canonical workflow token. The pull_request token is scoped
# down for forks and rejects cross-repo `gh` calls against external
# Eidolon repos. Re-add the pull_request trigger only if/when we accept
# fork contributions and re-architect the cross-repo verifier.)
on:
push:
paths:
- 'roster/**'
- 'cli/**'
- 'schemas/**'
- '.github/workflows/roster-health.yml'
schedule:
# Nightly at 03:00 UTC — catches upstream Eidolon repos going bad
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
validate-roster:
name: Validate roster schema
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq
# mikefarah/yq
sudo wget -qO /usr/local/bin/yq \
https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
- name: Validate roster/index.yaml structure
run: |
set -euo pipefail
yq eval '.' roster/index.yaml > /tmp/roster.json
# Every entry must have required fields
for name in $(jq -r '.eidolons[].name' /tmp/roster.json); do
echo "Validating $name..."
jq -e --arg n "$name" '.eidolons[] | select(.name == $n) |
.methodology.name and .methodology.version and .methodology.cycle and
.source.repo and
.versions.latest and
.handoffs.upstream and .handoffs.downstream' \
/tmp/roster.json > /dev/null || { echo "::error::$name missing required fields"; exit 1; }
done
- name: Validate JSON schemas themselves
run: |
for schema in schemas/*.json; do
echo "Checking $schema is valid JSON..."
jq empty "$schema"
done
- name: Validate shipped-status integrity metadata posture
# Story 5.H: a roster entry with status=shipped must declare release
# integrity metadata under versions.releases.<latest>. Compatibility
# mode (integrity.enforcement: warn) makes this a warning; strict
# mode promotes it to a hard failure. in_construction Eidolons skip
# — consistent with the EIIS-conformance matrix job.
run: |
set -euo pipefail
enforcement="$(yq -r '.integrity.enforcement // "warn"' roster/index.yaml)"
missing_count=0
while IFS= read -r name; do
[[ -n "$name" ]] || continue
status="$(yq -r ".eidolons[] | select(.name == \"$name\") | .status" roster/index.yaml)"
if [[ "$status" != "shipped" ]]; then
continue
fi
latest="$(yq -r ".eidolons[] | select(.name == \"$name\") | .versions.latest" roster/index.yaml)"
meta="$(yq -o=json ".eidolons[] | select(.name == \"$name\") | .versions.releases.\"$latest\" // {}" roster/index.yaml)"
if [[ "$meta" == "{}" ]]; then
if [[ "$enforcement" == "strict" ]]; then
echo "::error::$name is shipped at v$latest but versions.releases.\"$latest\" is missing"
missing_count=$((missing_count + 1))
else
echo "::warning::$name is shipped at v$latest without release integrity metadata (compatibility mode)"
fi
fi
done < <(yq -r '.eidolons[].name' roster/index.yaml)
if [[ "$missing_count" -gt 0 ]]; then
exit 1
fi
parse-mcp-catalogue:
name: Validate MCP catalogue
runs-on: ubuntu-latest
needs: validate-roster
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq
sudo wget -qO /usr/local/bin/yq \
https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
- name: Parse roster/mcps.yaml
run: |
set -euo pipefail
yq eval '.' roster/mcps.yaml > /dev/null
echo "roster/mcps.yaml parses cleanly"
- name: Validate MCP catalogue schema is valid JSON
run: |
jq empty schemas/mcp-catalogue.schema.json
jq empty schemas/mcp-lockfile.schema.json
echo "MCP schemas are valid JSON"
- name: Check catalogue entries have required fields
run: |
set -euo pipefail
yq -o=json eval '.' roster/mcps.yaml > /tmp/mcps.json
for name in $(jq -r '.mcps[].name' /tmp/mcps.json); do
echo "Validating MCP entry: $name ..."
jq -e --arg n "$name" '.mcps[] | select(.name == $n) |
.display_name and .scope and .kind and .description and
.source.type and .versions.latest and .versions.pins.stable and
(.health.probes | length > 0)' \
/tmp/mcps.json > /dev/null \
|| { echo "::error::MCP $name missing required fields"; exit 1; }
done
echo "All MCP catalogue entries valid"
mcp-catalogue-digest-reachable:
name: MCP catalogue digest reachability (non-fatal)
runs-on: ubuntu-latest
needs: parse-mcp-catalogue
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq curl
sudo wget -qO /usr/local/bin/yq \
https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
- name: Probe oci-image digests reachability on ghcr.io
run: |
set -uo pipefail
yq -o=json eval '.' roster/mcps.yaml > /tmp/mcps.json
warn_count=0
while IFS= read -r entry; do
name="$(printf '%s' "$entry" | jq -r '.name')"
kind="$(printf '%s' "$entry" | jq -r '.kind')"
if [ "$kind" != "oci-image" ]; then
continue
fi
image="$(printf '%s' "$entry" | jq -r '.source.image // ""')"
digest="$(printf '%s' "$entry" \
| jq -r '.versions.releases | to_entries[0].value.digest // ""')"
if [ -z "$image" ] || [ -z "$digest" ]; then
echo "::warning::$name: no image or digest in catalogue — skipping reachability probe"
continue
fi
full_ref="${image}@${digest}"
namespace="${image#ghcr.io/}"
repo_part="${namespace#*/}"
ns_part="${namespace%%/*}"
token_url="https://ghcr.io/token?scope=repository:${ns_part}/${repo_part}:pull"
token_json="$(curl -fsSL "$token_url" 2>/dev/null)" || {
echo "::warning::$name: could not fetch ghcr.io token (offline?)"
warn_count=$((warn_count + 1))
continue
}
token="$(printf '%s' "$token_json" | jq -r '.token // empty')"
if [ -z "$token" ]; then
echo "::warning::$name: empty token from ghcr.io"
warn_count=$((warn_count + 1))
continue
fi
manifest_url="https://ghcr.io/v2/${ns_part}/${repo_part}/manifests/${digest}"
http_status="$(curl -fsI \
-H "Authorization: Bearer ${token}" \
-o /dev/null \
-w '%{http_code}' \
"$manifest_url" 2>/dev/null)" || http_status=""
if [ "$http_status" = "200" ]; then
echo "$name ($full_ref): reachable"
else
echo "::warning::$name: manifest returned '${http_status:-no response}' for $full_ref"
warn_count=$((warn_count + 1))
fi
done < <(jq -c '.mcps[]' /tmp/mcps.json)
if [ "$warn_count" -gt 0 ]; then
echo "::warning::$warn_count MCP digest(s) unreachable (non-fatal — may be offline or yanked)"
fi
check-eidolon-repos:
name: Check every Eidolon repo is reachable and EIIS-conformant
runs-on: ubuntu-latest
needs: validate-roster
strategy:
fail-fast: false
matrix:
eidolon: [atlas, spectra, ramza, apivr, vivi, idg, forge, vigil, kupo]
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq
sudo wget -qO /usr/local/bin/yq \
https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
- name: Resolve repo for ${{ matrix.eidolon }}
id: resolve
run: |
REPO=$(yq -r ".eidolons[] | select(.name == \"${{ matrix.eidolon }}\") | .source.repo" roster/index.yaml)
STATUS=$(yq -r ".eidolons[] | select(.name == \"${{ matrix.eidolon }}\") | .status" roster/index.yaml)
LATEST=$(yq -r ".eidolons[] | select(.name == \"${{ matrix.eidolon }}\") | .versions.latest" roster/index.yaml)
echo "repo=$REPO" >> "$GITHUB_OUTPUT"
echo "status=$STATUS" >> "$GITHUB_OUTPUT"
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
echo "tag=v$LATEST" >> "$GITHUB_OUTPUT"
- name: Skip in-construction Eidolons
if: steps.resolve.outputs.status == 'in_construction'
run: echo "::notice::${{ matrix.eidolon }} is in_construction — skipping conformance check"
- name: Clone ${{ matrix.eidolon }}
if: steps.resolve.outputs.status == 'shipped'
run: |
git clone --depth 1 --branch "${{ steps.resolve.outputs.tag }}" \
"https://github.com/${{ steps.resolve.outputs.repo }}" /tmp/eidolon
- name: Verify release integrity metadata
if: steps.resolve.outputs.status == 'shipped'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
name="${{ matrix.eidolon }}"
version="${{ steps.resolve.outputs.latest }}"
meta="$(yq -o=json ".eidolons[] | select(.name == \"$name\") | .versions.releases.\"$version\" // {}" roster/index.yaml)"
if [[ "$meta" == "{}" ]]; then
enforcement="$(yq -r '.integrity.enforcement // "warn"' roster/index.yaml)"
if [[ "$enforcement" == "strict" ]]; then
echo "::error::$name@$version missing release integrity metadata"
exit 1
fi
echo "::warning::$name@$version missing release integrity metadata (compatibility mode)"
exit 0
fi
expected_commit="$(echo "$meta" | jq -r '.commit')"
expected_tree="$(echo "$meta" | jq -r '.tree')"
actual_commit="$(git -C /tmp/eidolon rev-parse HEAD)"
actual_tree="$(git -C /tmp/eidolon rev-parse 'HEAD^{tree}')"
[[ "$actual_commit" == "$expected_commit" ]] || { echo "::error::$name commit mismatch"; exit 1; }
[[ "$actual_tree" == "$expected_tree" ]] || { echo "::error::$name tree mismatch"; exit 1; }
if [[ "$(echo "$meta" | jq -r '.provenance.github_attestation // false')" == "true" ]]; then
# Public Eidolon repos serve release-manifest.json directly via
# GitHub's release CDN — no auth needed. Using curl rather than
# `gh release download` avoids the cross-repo GITHUB_TOKEN scope
# problem (the default token only has access to this repo, not
# to Rynaro/<EIDOLON>; cross-repo gh API calls return HTTP 401).
mkdir -p /tmp/eidolon-release
release_url="https://github.com/${{ steps.resolve.outputs.repo }}/releases/download/v$version/release-manifest.json"
if ! curl -fsSL "$release_url" -o /tmp/eidolon-release/release-manifest.json; then
echo "::error::could not download release-manifest.json from $release_url"
exit 1
fi
# Attestation verify uses Sigstore's public transparency log; it
# tolerates an unauthenticated GH_TOKEN for public attestations.
# If this step starts failing on cross-repo scope, gate it on
# `github.event_name != 'pull_request'` (currently no-op since
# pull_request was dropped from the trigger block above).
gh attestation verify /tmp/eidolon-release/release-manifest.json \
--repo "${{ steps.resolve.outputs.repo }}" \
--signer-workflow "${{ github.repository }}/.github/workflows/eidolon-release-template.yml"
fi
- name: Resolve EIIS tag
if: steps.resolve.outputs.status == 'shipped'
id: eiis
run: |
set -euo pipefail
EIIS_REQ=$(yq -r '.eiis_required' roster/index.yaml)
# Roster pins `eiis_required` as a major.minor compat declaration;
# actual tags are full SemVer. Resolve to the highest matching patch.
if [[ "$EIIS_REQ" =~ ^[0-9]+\.[0-9]+$ ]]; then
EIIS_TAG=$(git ls-remote --tags --refs https://github.com/Rynaro/eidolons-eiis | \
awk -v p="refs/tags/v${EIIS_REQ}." '$2 ~ "^"p { sub("refs/tags/v", "", $2); print $2 }' | \
sort -V | tail -1)
fi
[[ -z "${EIIS_TAG:-}" ]] && EIIS_TAG="$EIIS_REQ"
echo "tag=v$EIIS_TAG" >> "$GITHUB_OUTPUT"
echo "Resolved eiis_required=$EIIS_REQ → tag v$EIIS_TAG"
- name: Clone Rynaro/eidolons-eiis@${{ steps.eiis.outputs.tag }}
if: steps.resolve.outputs.status == 'shipped'
run: |
git clone --depth 1 --branch "${{ steps.eiis.outputs.tag }}" \
"https://github.com/Rynaro/eidolons-eiis" /tmp/eiis
- name: EIIS conformance check (external checker)
if: steps.resolve.outputs.status == 'shipped'
run: |
set +e
bash /tmp/eiis/conformance/check.sh /tmp/eidolon
rc=$?
# Exit-code semantics from Rynaro/eidolons-eiis conformance/check.sh:
# 0 — all green (MUSTs + SHOULDs)
# 2 — MUST violation (hard fail)
# 3 — SHOULD violation (advisory; fail CI to surface drift)
# 4 — warn-only / grandfathered (e.g. missing manifest fixture).
# Per the EIIS-1.1 "warn-only first wave" stance, this is
# not a CI blocker — surface as a warning instead.
case "$rc" in
0)
echo "::notice::${{ matrix.eidolon }} passes EIIS ${{ steps.eiis.outputs.tag }} conformance"
;;
4)
echo "::warning::${{ matrix.eidolon }} passes MUSTs but has grandfathered SHOULD warnings (exit 4)"
;;
*)
echo "::error::${{ matrix.eidolon }} fails EIIS ${{ steps.eiis.outputs.tag }} conformance (exit $rc)"
exit 1
;;
esac
- name: Verify install.sh exits cleanly with --help
if: steps.resolve.outputs.status == 'shipped'
run: |
cd /tmp/eidolon
bash install.sh --help > /dev/null
- name: Verify install.sh --non-interactive succeeds (budget gate)
if: steps.resolve.outputs.status == 'shipped'
run: |
set -euo pipefail
cd /tmp/eidolon
target="$(mktemp -d)/.eidolons/${{ matrix.eidolon }}"
# A full non-interactive install exercises gates that --help and EIIS
# conformance never reach — notably each Eidolon's always-loaded
# agent.md ≤1000-token P0 budget, which install.sh hard-exits 4 on
# when blown. This is the gate that would have caught ATLAS v1.9.0 /
# VIGIL v1.4.0 shipping an over-budget agent.md (the CRYSTALIUM
# memory-mandate regression). --hosts is pinned because the clone has
# no host markers to auto-detect.
if ! bash install.sh --non-interactive --hosts claude-code --target "$target"; then
echo "::error::${{ matrix.eidolon }} install.sh --non-interactive failed (agent.md token budget or install-layout regression)"
exit 1
fi
# CLI smoke testing has moved to .github/workflows/ci.yml (install-e2e +
# cli-tests jobs, which cover every command across Ubuntu and macOS with
# bats). This workflow now focuses on its namesake: verifying the roster
# itself is valid and every upstream Eidolon repo is reachable + EIIS-
# conformant (runs on path-filtered pushes and nightly).
nexus-integrity:
name: Nexus release integrity
runs-on: ubuntu-latest
needs: validate-roster
# Skip if roster nexus block is absent (old roster) — defensive guard for
# forks/PRs that pre-date the nexus: block.
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update -q
sudo apt-get install -y jq
sudo wget -qO /usr/local/bin/yq \
https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
- name: Check nexus integrity metadata
run: |
set -euo pipefail
# Read latest nexus version from roster.
latest="$(yq -r '.nexus.versions.latest // ""' roster/index.yaml)"
if [[ -z "$latest" || "$latest" == "null" ]]; then
echo "::notice::nexus: block absent from roster — skipping integrity check (bootstrap window)"
exit 0
fi
echo "Nexus latest: v$latest"
# Read metadata.
commit="$(yq -r ".nexus.versions.releases[\"$latest\"].commit // \"\"" roster/index.yaml)"
# Skip if placeholder (bootstrap window before first release workflow run).
case "$commit" in
"<"*|"")
echo "::notice::nexus@$latest has placeholder commit — skipping integrity check (bootstrap window)"
exit 0
;;
esac
# Clone the tagged nexus.
git clone --depth 1 --branch "v$latest" \
https://github.com/Rynaro/eidolons /tmp/nexus-check
actual_commit="$(git -C /tmp/nexus-check rev-parse HEAD)"
actual_tree="$(git -C /tmp/nexus-check rev-parse 'HEAD^{tree}')"
expected_tree="$(yq -r ".nexus.versions.releases[\"$latest\"].tree // \"\"" roster/index.yaml)"
expected_archive="$(yq -r ".nexus.versions.releases[\"$latest\"].archive_sha256 // \"\"" roster/index.yaml)"
[[ "$actual_commit" == "$commit" ]] \
|| { echo "::error::nexus commit mismatch: got $actual_commit, expected $commit"; exit 1; }
[[ -z "$expected_tree" ]] || [[ "${expected_tree:0:1}" == "<" ]] \
|| [[ "$actual_tree" == "$expected_tree" ]] \
|| { echo "::error::nexus tree mismatch: got $actual_tree, expected $expected_tree"; exit 1; }
if [[ -n "$expected_archive" && "${expected_archive:0:1}" != "<" ]]; then
tmp_tar="$(mktemp)"
git -C /tmp/nexus-check archive --format=tar --prefix="eidolons-${latest}/" HEAD \
| gzip > "$tmp_tar"
# Compare the .tar.gz sha256 (gzip deterministic with consistent settings).
# Note: git archive produces deterministic output; gzip without timestamp matches workflow.
# For now check the raw tar sha256 to match what the workflow records.
git -C /tmp/nexus-check archive --format=tar --prefix="eidolons-${latest}/" HEAD \
> /tmp/nexus.tar
if command -v sha256sum >/dev/null 2>&1; then
actual_archive="$(sha256sum /tmp/nexus.tar | awk '{print $1}')"
else
actual_archive="$(shasum -a 256 /tmp/nexus.tar | awk '{print $1}')"
fi
rm -f /tmp/nexus.tar "$tmp_tar"
[[ "$actual_archive" == "$expected_archive" ]] \
|| { echo "::error::nexus archive sha256 mismatch: got $actual_archive, expected $expected_archive"; exit 1; }
fi
echo "::notice::nexus@$latest integrity verified (commit=$actual_commit)"
verify-ecl-pin:
name: Verify composition ECL pin
runs-on: ubuntu-latest
# Nightly belt-and-suspenders for the composition-drift contract. The
# dedicated composition-drift.yml gate only fires when composition.md /
# its template / that workflow change; it cannot catch a pinned ECL tag
# that is later moved or deleted upstream, nor silent drift on nights when
# nothing in the nexus touched composition.md. This job re-runs the same
# regeneration on the nightly cron so the pin (currently v2.1.1) stays
# honest against Rynaro/eidolons-ecl.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Read the pinned ECL tag from composition-drift.yml
id: pin
run: |
set -euo pipefail
tag="$(grep -oE 'ECL_PINNED_TAG:[[:space:]]*v[0-9]+\.[0-9]+\.[0-9]+' \
.github/workflows/composition-drift.yml | awk '{print $2}')"
[[ -n "$tag" ]] || { echo "::error::could not read ECL_PINNED_TAG from composition-drift.yml"; exit 1; }
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Pinned ECL tag: $tag"
- name: Verify the pinned tag still exists upstream
run: |
set -euo pipefail
tag="${{ steps.pin.outputs.tag }}"
if ! git ls-remote --tags https://github.com/Rynaro/eidolons-ecl "refs/tags/${tag}" \
| grep -q "refs/tags/${tag}"; then
echo "::error::pinned ECL tag ${tag} no longer resolves on Rynaro/eidolons-ecl (moved or deleted)."
exit 1
fi
echo "::notice::ECL pin ${tag} is reachable upstream."
- name: Clone eidolons-ecl at the pinned tag
run: |
git clone --depth 1 --branch "${{ steps.pin.outputs.tag }}" \
https://github.com/Rynaro/eidolons-ecl /tmp/ecl
- name: Install eidolons-ecl-sdk from the pinned source
run: pip install --quiet /tmp/ecl/reference-sdk/py
- name: Verify local template matches the pinned ecl template
run: |
diff -u \
/tmp/ecl/reference-sdk/py/src/eidolons_ecl/compose_gen/templates/composition.md.j2 \
methodology/composition.md.j2 \
|| {
echo "::error::methodology/composition.md.j2 drifted from pinned eidolons-ecl@${{ steps.pin.outputs.tag }}."
exit 1
}
- name: Regenerate and diff composition.md against the pinned contracts
run: |
eidolons-ecl compose-gen \
--contracts /tmp/ecl/contracts \
--template methodology/composition.md.j2 \
--out /tmp/regen.md
diff -u methodology/composition.md /tmp/regen.md \
|| {
echo "::error::methodology/composition.md drifted from the regenerated output for pinned tag ${{ steps.pin.outputs.tag }}."
exit 1
}
echo "::notice::composition.md matches eidolons-ecl@${{ steps.pin.outputs.tag }} (pin verified)."