Skip to content

fix(deps): update toolhive (#814) #1204

fix(deps): update toolhive (#814)

fix(deps): update toolhive (#814) #1204

name: Build MCP Server Containers
on:
push:
branches: [ main ]
paths:
- 'npx/**/*.yaml'
- 'uvx/**/*.yaml'
- 'go/**/*.yaml'
- 'cmd/dockhand/**'
- 'scripts/mcp-scan/**'
- '.github/workflows/build-containers.yml'
- '.github/workflows/mcp-scan-report.yml'
- 'go.mod'
- 'go.sum'
pull_request:
branches: [ main ]
paths:
- 'npx/**/*.yaml'
- 'uvx/**/*.yaml'
- 'go/**/*.yaml'
- 'cmd/dockhand/**'
- 'scripts/mcp-scan/**'
- '.github/workflows/build-containers.yml'
- '.github/workflows/mcp-scan-report.yml'
- 'go.mod'
- 'go.sum'
workflow_dispatch:
permissions: {}
# Never cancel a run outright — each push or PR update publishes/signs/
# attests images for that specific commit, and cancelling one would
# silently skip its release. queue: max keeps every queued run pending
# instead of just the latest, since the default queue depth of 1 would
# otherwise drop a run sandwiched between two others. GitHub rejects
# queue: max combined with a cancel-in-progress that can resolve to true,
# so this can no longer cancel superseded PR runs the way it used to.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
queue: max
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
discover-configs:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
configs: ${{ steps.find-configs.outputs.configs }}
changed-configs: ${{ steps.find-configs.outputs.changed-configs }}
scan-configs: ${{ steps.find-configs.outputs.scan-configs }}
smoke: ${{ steps.find-configs.outputs.smoke }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # Need full history for change detection
- name: Find configuration files to build
id: find-configs
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
run: |
# Find all spec.yaml files in protocol directories
all_configs=$(find npx uvx go -name "spec.yaml" -type f 2>/dev/null | sort)
# Decide whether a change to core build inputs warrants rebuilding
# every container. dockhand source always triggers a full rebuild
# (it generates the Dockerfiles). go.mod/go.sum only count when the
# toolhive modules changed — they drive Dockerfile generation, so a
# bump there can alter every image. Unrelated dependency bumps must
# not rebuild everything and flood GHCR.
# Args: $1 = git diff range, $2 = newline-separated changed files.
core_rebuild_needed() {
local range="$1" changed="$2"
if echo "$changed" | grep -qE "(cmd/dockhand/)"; then
return 0
fi
if echo "$changed" | grep -qE "^go\.(mod|sum)$"; then
if git diff "$range" -- go.mod go.sum | grep -qE '^[+-].*stacklok/toolhive'; then
return 0
fi
fi
return 1
}
# Smoke mode: set when the only relevant change is to this workflow
# or the scan scripts. See the block after the event dispatch below.
smoke=false
if [ "$EVENT_NAME" == "workflow_dispatch" ]; then
# For manual triggers, build all configs
configs_to_build="$all_configs"
configs_to_scan="$all_configs"
echo "Manual trigger - building all configurations"
elif [ "$EVENT_NAME" == "pull_request" ]; then
# For PRs, build configs that changed compared to target branch
diff_range="origin/${BASE_REF}...HEAD"
changed_files=$(git diff --name-only "$diff_range")
configs_to_build=""
configs_to_scan=""
# Find configs whose spec.yaml files actually changed
for config in $all_configs; do
config_dir=$(dirname "$config")
if echo "$changed_files" | grep -q "^$config$" || echo "$changed_files" | grep -q "^$config_dir/"; then
configs_to_build="$configs_to_build$config"$'\n'
configs_to_scan="$configs_to_scan$config"$'\n'
fi
done
# If core build inputs changed, rebuild all containers but only
# scan the spec.yaml files that actually changed.
if core_rebuild_needed "$diff_range" "$changed_files"; then
echo "Core build inputs changed - rebuilding all containers (scanning only changed specs)"
configs_to_build="$all_configs"
fi
else
# For pushes to main, build configs that changed in this push
diff_range="HEAD~1..HEAD"
changed_files=$(git diff --name-only "$diff_range")
configs_to_build=""
configs_to_scan=""
# Find configs whose spec.yaml files actually changed
for config in $all_configs; do
config_dir=$(dirname "$config")
if echo "$changed_files" | grep -q "^$config$" || echo "$changed_files" | grep -q "^$config_dir/"; then
configs_to_build="$configs_to_build$config"$'\n'
configs_to_scan="$configs_to_scan$config"$'\n'
fi
done
# If core build inputs changed, rebuild all containers but only
# scan the spec.yaml files that actually changed.
if core_rebuild_needed "$diff_range" "$changed_files"; then
echo "Core build inputs changed - rebuilding all containers (scanning only changed specs)"
configs_to_build="$all_configs"
fi
fi
# If the only relevant change was to this workflow or the scan
# scripts (no spec changed, no full rebuild triggered), run a small
# representative sample — one config per protocol — through the
# verify-provenance and mcp-security-scan legs. This exercises the
# scan/verify toolchain (e.g. a Python or scanner bump) without
# building or pushing every image. build-containers is skipped for
# smoke runs via the `smoke` output.
if [ "$EVENT_NAME" != "workflow_dispatch" ] \
&& [ -z "$configs_to_build" ] \
&& echo "$changed_files" | grep -qE "(^\.github/workflows/build-containers\.yml$|^\.github/workflows/mcp-scan-report\.yml$|^scripts/mcp-scan/)"; then
echo "Only workflow/scan-script changed - smoke testing a representative sample (no build/push)"
sample=""
for proto in npx uvx go; do
first=$(find "$proto" -name "spec.yaml" -type f 2>/dev/null | sort | head -1)
if [ -n "$first" ]; then
sample="$sample$first"$'\n'
fi
done
configs_to_build="$sample"
configs_to_scan="$sample"
smoke=true
fi
# Convert to JSON array, filtering out empty lines
configs_json=$(echo "$configs_to_build" | grep -v '^$' | jq -R -s -c 'split("\n")[:-1]')
scan_configs_json=$(echo "$configs_to_scan" | grep -v '^$' | jq -R -s -c 'split("\n")[:-1]')
all_configs_json=$(echo "$all_configs" | jq -R -s -c 'split("\n")[:-1]')
echo "configs=$all_configs_json" >> $GITHUB_OUTPUT
echo "changed-configs=$configs_json" >> $GITHUB_OUTPUT
echo "scan-configs=$scan_configs_json" >> $GITHUB_OUTPUT
echo "smoke=$smoke" >> $GITHUB_OUTPUT
echo "All configurations: $all_configs_json"
echo "Configurations to build: $configs_json"
verify-provenance:
needs: discover-configs
runs-on: ubuntu-latest
# Verify package provenance when we have configs to build
if: ${{ needs.discover-configs.outputs.changed-configs != '[]' }}
strategy:
matrix:
config: ${{ fromJson(needs.discover-configs.outputs.changed-configs) }}
fail-fast: false
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: 'go.mod'
- name: Extract metadata from config
id: meta
env:
CONFIG_FILE: ${{ matrix.config }}
run: |
config_file="$CONFIG_FILE"
protocol=$(echo "$config_file" | cut -d'/' -f1)
server_name=$(echo "$config_file" | cut -d'/' -f2)
echo "config_file=$config_file" >> $GITHUB_OUTPUT
echo "protocol=$protocol" >> $GITHUB_OUTPUT
echo "server_name=$server_name" >> $GITHUB_OUTPUT
- name: Build dockhand
run: go build -o /tmp/dockhand ./cmd/dockhand
- name: Verify package provenance
id: provenance
env:
SERVER_NAME: ${{ steps.meta.outputs.server_name }}
CONFIG_FILE: ${{ matrix.config }}
run: |
echo "🔍 Verifying package provenance for $SERVER_NAME"
# Run provenance verification
if /tmp/dockhand verify-provenance -c "$CONFIG_FILE" > "/tmp/provenance-${SERVER_NAME}.txt" 2>&1; then
echo "provenance_passed=true" >> $GITHUB_OUTPUT
echo "✅ Provenance verification passed" >> $GITHUB_STEP_SUMMARY
else
echo "provenance_passed=false" >> $GITHUB_OUTPUT
echo "⚠️ Provenance verification failed or package has no provenance" >> $GITHUB_STEP_SUMMARY
fi
# Display results
cat "/tmp/provenance-${SERVER_NAME}.txt"
# Save for artifact
cp "/tmp/provenance-${SERVER_NAME}.txt" .
- name: Upload provenance verification results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: provenance-${{ steps.meta.outputs.server_name }}
path: provenance-${{ steps.meta.outputs.server_name }}.txt
retention-days: 30
mcp-security-scan:
needs: [discover-configs, verify-provenance]
runs-on: ubuntu-latest
timeout-minutes: 10
# Only scan configs whose spec.yaml files actually changed
# (not all configs when only go.mod/dockhand changed, since scans don't use dockhand)
if: ${{ needs.discover-configs.outputs.scan-configs != '[]' }}
strategy:
matrix:
config: ${{ fromJson(needs.discover-configs.outputs.scan-configs) }}
fail-fast: false
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.13'
- name: Set up uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
- name: Install dependencies
id: install-deps
run: |
# Install pinned Python dependencies from requirements.txt
uv pip install --system -r scripts/mcp-scan/requirements.txt
# Verify installation
mcp-scanner --help || true
# Capture scanner version for attestation
SCANNER_VERSION=$(uv pip show cisco-ai-mcp-scanner | grep "^Version:" | cut -d' ' -f2)
echo "scanner_version=$SCANNER_VERSION" >> $GITHUB_OUTPUT
echo "Installed mcp-scanner version: $SCANNER_VERSION"
- name: Extract metadata from config
id: meta
env:
CONFIG_FILE: ${{ matrix.config }}
run: |
config_file="$CONFIG_FILE"
# Extract protocol and server name from path like "npx/context7/spec.yaml"
protocol=$(echo "$config_file" | cut -d'/' -f1)
server_name=$(echo "$config_file" | cut -d'/' -f2)
echo "config_file=$config_file" >> $GITHUB_OUTPUT
echo "protocol=$protocol" >> $GITHUB_OUTPUT
echo "server_name=$server_name" >> $GITHUB_OUTPUT
- name: Pre-install npm package
if: steps.meta.outputs.protocol == 'npx'
env:
CONFIG_FILE: ${{ matrix.config }}
run: |
# mcp-scanner has a 60s timeout for server startup. npx downloading
# a package can exceed this, so pre-install it globally first.
package=$(python3 -c "
import yaml
with open('$CONFIG_FILE') as f:
data = yaml.safe_load(f)
spec = data['spec']
print(spec['package'] + '@' + spec.get('version', 'latest'))
")
echo "Pre-installing $package..."
npm install --global "$package"
- name: Run MCP Security Scan
id: scan
env:
MCP_SCANNER_ENABLE_LLM: ${{ vars.MCP_SCANNER_ENABLE_LLM || 'false' }}
MCP_SCANNER_LLM_API_KEY: ${{ secrets.MCP_SCANNER_LLM_API_KEY }}
MCP_SCANNER_LLM_MODEL: ${{ vars.MCP_SCANNER_LLM_MODEL || '' }}
# Severity threshold for blocking. Findings below this severity are
# surfaced as warnings but do not fail the job. One of: INFO, LOW,
# MEDIUM, HIGH, CRITICAL. Tune via PR.
MCP_SCANNER_BLOCK_SEVERITY: HIGH
SERVER_NAME: ${{ steps.meta.outputs.server_name }}
PROTOCOL: ${{ steps.meta.outputs.protocol }}
CONFIG_FILE: ${{ matrix.config }}
SCANNER_VERSION: ${{ steps.install-deps.outputs.scanner_version }}
run: |
echo "🔍 Scanning MCP server: $SERVER_NAME"
# Generate config (outputs JSON with command/args/mock_env)
config_json=$(python3 scripts/mcp-scan/generate_mcp_config.py \
"$CONFIG_FILE" \
"$PROTOCOL" \
"$SERVER_NAME")
# Write config to file for run_scan.py
scan_config="/tmp/scan-config-${SERVER_NAME}.json"
echo "$config_json" > "$scan_config"
# Run scan using Cisco AI Defense mcp-scanner
# Note: stderr is redirected to a separate file to avoid corrupting JSON output
scan_output="/tmp/mcp-scan-${SERVER_NAME}.json"
scan_stderr="/tmp/mcp-scan-${SERVER_NAME}.stderr"
if python3 scripts/mcp-scan/run_scan.py --config "$scan_config" \
> "$scan_output" 2> "$scan_stderr"; then
echo "scan_passed=true" >> $GITHUB_OUTPUT
else
echo "scan_passed=false" >> $GITHUB_OUTPUT
# Show stderr in logs for debugging
if [ -s "$scan_stderr" ]; then
echo "Scanner stderr output:"
cat "$scan_stderr"
fi
fi
# Process results (pass the config file to check for allowed issues)
python3 scripts/mcp-scan/process_scan_results.py \
"$scan_output" \
"$SERVER_NAME" \
"$CONFIG_FILE" \
> scan-summary.json
# Copy scan output to current directory for artifact upload
cp "$scan_output" "mcp-scan-${SERVER_NAME}.json"
# Save scanner version for attestation (from install-deps step)
echo "$SCANNER_VERSION" > scanner-version.txt
- name: Upload scan results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mcp-scan-${{ steps.meta.outputs.server_name }}
path: |
mcp-scan-${{ steps.meta.outputs.server_name }}.json
scan-summary.json
scanner-version.txt
retention-days: 30
build-containers:
needs: [discover-configs, verify-provenance, mcp-security-scan]
runs-on: ubuntu-latest
timeout-minutes: 60
# `!cancelled()` disables the implicit success() gate on `needs` so this
# job still runs when mcp-security-scan was skipped (e.g. a toolhive bump
# that rebuilds every container but changes no spec, so nothing needs
# re-scanning). Without it, a skipped scan silently skipped the whole
# build. The security gate is preserved per-cell by the `Pre-flight scan
# gate` step below, which reads scan-summary.json from the artifact
# produced by mcp-security-scan. verify-provenance is informational.
#
# On smoke runs (workflow/scan-script-only changes) this builds the
# representative sample on PRs to exercise the build path (Dockerfile
# generation, buildx, Grype) but never pushes — the push/sign/attest
# steps are already gated on `github.event_name != 'pull_request'`. On
# pushes to main, smoke skips the build entirely so a workflow edit does
# not publish, sign, or attest a sample image.
if: ${{ !cancelled() && needs.discover-configs.outputs.changed-configs != '[]' && needs.verify-provenance.result == 'success' && (needs.discover-configs.outputs.smoke != 'true' || github.event_name == 'pull_request') }}
strategy:
matrix:
config: ${{ fromJson(needs.discover-configs.outputs.changed-configs) }}
fail-fast: false
# Cap concurrency so a full rebuild (toolhive bump) doesn't push images,
# signatures, and attestations from every cell at once and burst past
# GHCR's manifest write limit. Only bites when there are more cells than
# this; single-spec changes are unaffected.
max-parallel: 20
permissions:
contents: read
packages: write
id-token: write # Needed for OIDC token (sigstore)
attestations: write # Needed for attestations
security-events: write # Needed for Grype SARIF upload
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: 'go.mod'
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Install yq
uses: mikefarah/yq@1b9b4ac5187171d2e5e3129be0cfa827c7f9d53d # v4.53.3
- name: Log in to Container Registry
# Only login when we're going to push (main branch or manual trigger)
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata from config
id: meta
env:
CONFIG_FILE: ${{ matrix.config }}
run: |
config_file="$CONFIG_FILE"
echo "config_file=$config_file" >> $GITHUB_OUTPUT
# Extract protocol from directory name (first part of path)
protocol=$(echo "$config_file" | cut -d'/' -f1)
echo "protocol=$protocol" >> $GITHUB_OUTPUT
# Extract server name from directory name (second part of path)
server_name=$(echo "$config_file" | cut -d'/' -f2)
echo "server_name=$server_name" >> $GITHUB_OUTPUT
# Extract version from YAML file (spec.version)
spec_version=$(yq '.spec.version' "$config_file" 2>/dev/null || echo "")
# Use spec.version if available, otherwise "latest"
if [ -n "$spec_version" ]; then
version="$spec_version"
else
version="latest"
fi
echo "version=$version" >> $GITHUB_OUTPUT
# Generate image name
image_name="${REGISTRY}/${IMAGE_NAME}/${protocol}/${server_name}"
echo "image_name=$image_name" >> $GITHUB_OUTPUT
# Pull the per-server security scan artifact before spending time on the
# build. `continue-on-error: true` so this succeeds when the artifact
# does not exist (this server was not in scan-configs for this run); the
# Pre-flight scan gate below decides whether that is acceptable.
- name: Download MCP security scan results
id: download-scan
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: mcp-scan-${{ steps.meta.outputs.server_name }}
path: /tmp/scan-results
continue-on-error: true
# Per-cell scan gate. Preserves the security gate at the individual
# server level now that the job-level `mcp-security-scan.result` check is
# gone (it skipped the whole build when the scan job was skipped).
# - If this server was in scan-configs, require a fresh scan-summary.json
# with status in {passed, warning}. Anything else fails the cell.
# - If it was NOT in scan-configs (e.g. a toolhive bump that rebuilds
# everything but re-scans nothing), proceed on prior trust: the
# previous run on main must have been clean for this pinned version.
- name: Pre-flight scan gate
env:
CONFIG: ${{ matrix.config }}
SCAN_CONFIGS: ${{ needs.discover-configs.outputs.scan-configs }}
SERVER_NAME: ${{ steps.meta.outputs.server_name }}
run: |
set -euo pipefail
summary=/tmp/scan-results/scan-summary.json
expected=$(jq -r --arg c "$CONFIG" 'index($c) // "missing"' <<<"$SCAN_CONFIGS")
if [ ! -f "$summary" ]; then
if [ "$expected" = "missing" ]; then
echo "No scan ran for ${SERVER_NAME} in this workflow (not in scan-configs); proceeding on prior trust."
exit 0
fi
echo "::error title=Scan artifact missing::scan-summary.json absent for ${SERVER_NAME} but the server was in scan-configs; refusing to publish."
exit 1
fi
status=$(jq -r '.status // "missing"' "$summary")
case "$status" in
passed|warning)
echo "MCP scan status for ${SERVER_NAME}: ${status} - publish allowed."
;;
*)
echo "::error title=Scan blocked publish::MCP server ${SERVER_NAME} scan status is '${status}'; refusing to publish."
jq -r '.blocking_issues[]? | " - [\(.code)] (\(.severity)) \(.message)"' "$summary" 2>/dev/null || true
exit 1
;;
esac
- name: Generate Dockerfile
id: dockerfile
env:
CONFIG_FILE: ${{ steps.meta.outputs.config_file }}
run: |
echo "Generating Dockerfile for $CONFIG_FILE"
# Create a temporary directory for the Dockerfile
dockerfile_dir=$(mktemp -d)
dockerfile_path="${dockerfile_dir}/Dockerfile"
# Build and run dockhand to generate the Dockerfile
go build -o /tmp/dockhand ./cmd/dockhand
/tmp/dockhand build --config "$CONFIG_FILE" --output "${dockerfile_path}"
echo "dockerfile_dir=$dockerfile_dir" >> $GITHUB_OUTPUT
echo "dockerfile_path=$dockerfile_path" >> $GITHUB_OUTPUT
# Display the generated Dockerfile for debugging
echo "Generated Dockerfile:"
cat "${dockerfile_path}"
- name: Build and push Docker image
id: build
timeout-minutes: 30
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ${{ steps.dockerfile.outputs.dockerfile_dir }}
file: ${{ steps.dockerfile.outputs.dockerfile_path }}
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: |
${{ steps.meta.outputs.image_name }}:${{ steps.meta.outputs.version }}
${{ steps.meta.outputs.image_name }}:latest
labels: |
org.opencontainers.image.title=${{ steps.meta.outputs.server_name }}
org.opencontainers.image.description=MCP server for ${{ steps.meta.outputs.server_name }}
org.opencontainers.image.vendor=Stacklok
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
sbom: true
provenance: true
- name: Build single-platform image for Grype scan
id: build-for-scan
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ${{ steps.dockerfile.outputs.dockerfile_dir }}
file: ${{ steps.dockerfile.outputs.dockerfile_path }}
load: true
tags: local-scan:${{ steps.meta.outputs.server_name }}-${{ steps.meta.outputs.version }}
cache-from: type=gha
- name: Sign container images with Cosign
if: github.event_name != 'pull_request'
env:
DIGEST: ${{ steps.build.outputs.digest }}
IMAGE_NAME: ${{ steps.meta.outputs.image_name }}
VERSION: ${{ steps.meta.outputs.version }}
run: |
# Retry cosign on non-zero exit. During a full rebuild the concurrent
# matrix cells push signatures fast enough to trip GHCR's manifest
# rate limit (TOOMANYREQUESTS); the retry-after is tiny, so a short
# linear backoff clears it. cosign does not retry these itself.
retry() {
local max_attempts=5 attempt=1 backoff
while :; do
if "$@"; then
return 0
fi
if [ "$attempt" -ge "$max_attempts" ]; then
echo "cosign failed after ${attempt} attempt(s): $*" >&2
return 1
fi
backoff=$(( 5 * attempt ))
echo "cosign failed; retrying in ${backoff}s: $*" >&2
sleep "$backoff"
attempt=$(( attempt + 1 ))
done
}
echo "Signing image ${IMAGE_NAME}@${DIGEST}"
retry cosign sign --yes "${IMAGE_NAME}@${DIGEST}"
# Also sign the tagged versions for better UX
retry cosign sign --yes "${IMAGE_NAME}:${VERSION}"
retry cosign sign --yes "${IMAGE_NAME}:latest"
echo "✅ Images signed with Sigstore/Cosign" >> $GITHUB_STEP_SUMMARY
- name: Create security scan attestation (SCAI format)
if: github.event_name != 'pull_request'
env:
DIGEST: ${{ steps.build.outputs.digest }}
IMAGE_NAME: ${{ steps.meta.outputs.image_name }}
CONFIG_FILE: ${{ matrix.config }}
COMMIT_SHA: ${{ github.sha }}
RUN_ID: ${{ github.run_id }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
run: |
# Prior-trust path: this server was not in scan-configs for this run
# (e.g. a toolhive bump that rebuilds everything but re-scans no
# specs). The previous SCAI attestation on the registry remains
# authoritative; skipping here is safe and intentional.
if [ ! -f /tmp/scan-results/scan-summary.json ]; then
echo "No fresh scan summary for this run; skipping SCAI attestation (server not in this run's scan-configs)."
exit 0
fi
echo "Creating SCAI security scan attestation for ${IMAGE_NAME}@${DIGEST}"
# Read scanner version from scan results
SCANNER_VERSION=""
if [ -f /tmp/scan-results/scanner-version.txt ]; then
SCANNER_VERSION=$(cat /tmp/scan-results/scanner-version.txt | tr -d '\n')
echo "Scanner version: $SCANNER_VERSION"
fi
# Generate SCAI attestation using Python script
# Reference: https://github.com/in-toto/attestation/blob/main/spec/predicates/scai.md
# Note: Uses github.server_url to support GitHub Enterprise Server
# Note: Analyzers are read from scan-summary.json (extracted from scanner output)
python3 scripts/mcp-scan/generate_scai_attestation.py \
/tmp/scan-results/scan-summary.json \
"$IMAGE_NAME" \
"${DIGEST}" \
--config-file "$CONFIG_FILE" \
--commit-sha "$COMMIT_SHA" \
--run-id "$RUN_ID" \
--run-url "${SERVER_URL}/${REPO}/actions/runs/${RUN_ID}" \
--producer-uri "${SERVER_URL}/${REPO}" \
--scanner-version "$SCANNER_VERSION" \
--scanner-uri "https://github.com/cisco-ai-defense/mcp-scanner" \
--validate \
--output /tmp/security-attestation.json
# Show the generated attestation for debugging
echo "Generated SCAI attestation:"
cat /tmp/security-attestation.json
# Attest the security scan results with SCAI predicate type. Retry
# against GHCR rate limiting (see the signing step); cosign does not
# retry TOOMANYREQUESTS itself.
retry() {
local max_attempts=5 attempt=1 backoff
while :; do
if "$@"; then
return 0
fi
if [ "$attempt" -ge "$max_attempts" ]; then
echo "cosign attest failed after ${attempt} attempt(s)" >&2
return 1
fi
backoff=$(( 5 * attempt ))
echo "cosign attest failed; retrying in ${backoff}s..." >&2
sleep "$backoff"
attempt=$(( attempt + 1 ))
done
}
retry cosign attest --yes \
--predicate /tmp/security-attestation.json \
--type https://in-toto.io/attestation/scai/v0.3 \
"${IMAGE_NAME}@${DIGEST}"
# Extract attribute for summary
ATTRIBUTE=$(jq -r '.predicate.attributes[0].attribute' /tmp/security-attestation.json)
echo "✅ SCAI security scan attestation created (${ATTRIBUTE})" >> $GITHUB_STEP_SUMMARY
# Clean up
rm -f /tmp/security-attestation.json
- name: Run Grype vulnerability scanner
id: grype-scan
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
image: "local-scan:${{ steps.meta.outputs.server_name }}-${{ steps.meta.outputs.version }}"
severity-cutoff: "high"
only-fixed: "true"
output-format: "sarif"
- name: Upload Grype results to GitHub Security
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
if: always()
with:
sarif_file: ${{ steps.grype-scan.outputs.sarif }}
category: 'grype-${{ steps.meta.outputs.server_name }}'
- name: Generate image summary
env:
CONFIG_FILE: ${{ steps.meta.outputs.config_file }}
PROTOCOL: ${{ steps.meta.outputs.protocol }}
SERVER_NAME: ${{ steps.meta.outputs.server_name }}
IMAGE_NAME: ${{ steps.meta.outputs.image_name }}
VERSION: ${{ steps.meta.outputs.version }}
DIGEST: ${{ steps.build.outputs.digest }}
EVENT_NAME: ${{ github.event_name }}
run: |
echo "## Container Build Summary" >> $GITHUB_STEP_SUMMARY
echo "- **Config**: $CONFIG_FILE" >> $GITHUB_STEP_SUMMARY
echo "- **Protocol**: $PROTOCOL" >> $GITHUB_STEP_SUMMARY
echo "- **Server**: $SERVER_NAME" >> $GITHUB_STEP_SUMMARY
echo "- **Image**: $IMAGE_NAME" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: $VERSION" >> $GITHUB_STEP_SUMMARY
echo "- **Platforms**: linux/amd64, linux/arm64" >> $GITHUB_STEP_SUMMARY
if [ "$EVENT_NAME" != "pull_request" ]; then
echo "- **SBOM**: ✅ Attested" >> $GITHUB_STEP_SUMMARY
echo "- **Build Provenance**: ✅ Attested" >> $GITHUB_STEP_SUMMARY
echo "- **Security Scan**: ✅ Attested" >> $GITHUB_STEP_SUMMARY
echo "- **Signatures**: ✅ Signed with Sigstore/Cosign" >> $GITHUB_STEP_SUMMARY
echo "- **Grype Scan**: ✅ Completed (see Security tab)" >> $GITHUB_STEP_SUMMARY
echo "- **Status**: ✅ Built, pushed, signed, and attested" >> $GITHUB_STEP_SUMMARY
echo "- **Tags**:" >> $GITHUB_STEP_SUMMARY
echo " - ${IMAGE_NAME}:${VERSION}" >> $GITHUB_STEP_SUMMARY
echo " - ${IMAGE_NAME}:latest" >> $GITHUB_STEP_SUMMARY
echo "- **Digest**: $DIGEST" >> $GITHUB_STEP_SUMMARY
else
echo "- **Grype Scan**: ✅ Completed (see Security tab)" >> $GITHUB_STEP_SUMMARY
echo "- **Status**: ✅ Built (not pushed - PR)" >> $GITHUB_STEP_SUMMARY
fi
# Save PR number as artifact for the mcp-scan-report workflow.
# This is needed because workflow_run doesn't reliably provide PR info for fork PRs.
save-pr-number:
runs-on: ubuntu-latest
permissions: {}
if: github.event_name == 'pull_request'
steps:
- name: Save PR number
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: echo "$PR_NUMBER" > pr-number.txt
- name: Upload PR number
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr-number
path: pr-number.txt
retention-days: 5
summary:
needs: [discover-configs, verify-provenance, mcp-security-scan, build-containers]
runs-on: ubuntu-latest
permissions: {}
if: always()
steps:
- name: Build Summary
env:
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
ALL_CONFIGS: ${{ needs.discover-configs.outputs.configs }}
CHANGED_CONFIGS: ${{ needs.discover-configs.outputs.changed-configs }}
PROVENANCE_RESULT: ${{ needs.verify-provenance.result }}
SCAN_RESULT: ${{ needs.mcp-security-scan.result }}
BUILD_RESULT: ${{ needs.build-containers.result }}
run: |
echo "## Dockyard Build Summary" >> $GITHUB_STEP_SUMMARY
echo "- **Trigger**: $EVENT_NAME" >> $GITHUB_STEP_SUMMARY
echo "- **Ref**: $REF" >> $GITHUB_STEP_SUMMARY
echo "- **Total Configs**: $ALL_CONFIGS" >> $GITHUB_STEP_SUMMARY
echo "- **Changed Configs**: $CHANGED_CONFIGS" >> $GITHUB_STEP_SUMMARY
if [ "$PROVENANCE_RESULT" == "success" ]; then
echo "- **Provenance Verification**: ✅ All packages verified" >> $GITHUB_STEP_SUMMARY
elif [ "$PROVENANCE_RESULT" == "failure" ]; then
echo "- **Provenance Verification**: ⚠️ Some packages have provenance issues (non-blocking)" >> $GITHUB_STEP_SUMMARY
elif [ "$PROVENANCE_RESULT" == "skipped" ]; then
echo "- **Provenance Verification**: ⏭️ Skipped" >> $GITHUB_STEP_SUMMARY
fi
if [ "$SCAN_RESULT" == "success" ]; then
echo "- **Security Scan**: ✅ All MCP servers passed security scanning" >> $GITHUB_STEP_SUMMARY
elif [ "$SCAN_RESULT" == "failure" ]; then
echo "- **Security Scan**: ❌ Some MCP servers have security issues" >> $GITHUB_STEP_SUMMARY
elif [ "$SCAN_RESULT" == "skipped" ]; then
echo "- **Security Scan**: ⏭️ Skipped" >> $GITHUB_STEP_SUMMARY
fi
if [ "$BUILD_RESULT" == "success" ]; then
echo "- **Build Status**: ✅ All changed containers built successfully" >> $GITHUB_STEP_SUMMARY
echo "- **Features**:" >> $GITHUB_STEP_SUMMARY
echo " - 🏗️ Multi-architecture support (amd64, arm64)" >> $GITHUB_STEP_SUMMARY
echo " - 📦 SBOM (Software Bill of Materials) included" >> $GITHUB_STEP_SUMMARY
echo " - 🔐 Provenance attestation for supply chain security" >> $GITHUB_STEP_SUMMARY
echo " - ✍️ Sigstore/Cosign signatures for image verification" >> $GITHUB_STEP_SUMMARY
echo " - 🚀 GitHub Actions cache for faster builds" >> $GITHUB_STEP_SUMMARY
elif [ "$BUILD_RESULT" == "failure" ]; then
echo "- **Build Status**: ❌ Some containers failed to build" >> $GITHUB_STEP_SUMMARY
elif [ "$BUILD_RESULT" == "skipped" ]; then
echo "- **Build Status**: ⏭️ No configuration changes detected" >> $GITHUB_STEP_SUMMARY
else
echo "- **Build Status**: ⚠️ Build status unknown" >> $GITHUB_STEP_SUMMARY
fi
# Add efficiency note
changed_count=$(echo "$CHANGED_CONFIGS" | jq length)
total_count=$(echo "$ALL_CONFIGS" | jq length)
echo "- **Efficiency**: Built $changed_count out of $total_count configurations" >> $GITHUB_STEP_SUMMARY