fix(deps): update toolhive (#814) #1204
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |