Skip to content

Merge pull request #141 from Agent-Hellboy/ci/trivy_operator_image_bu… #299

Merge pull request #141 from Agent-Hellboy/ci/trivy_operator_image_bu…

Merge pull request #141 from Agent-Hellboy/ci/trivy_operator_image_bu… #299

Workflow file for this run

name: CI
on:
pull_request:
branches: [ "*" ]
push:
branches: [ "main" ]
workflow_dispatch: {}
permissions:
contents: read
jobs:
changed-paths:
name: Detect Docs/Website Changes
runs-on: ubuntu-24.04
outputs:
code: ${{ steps.filter.outputs.code }}
docs: ${{ steps.filter.outputs.docs }}
website: ${{ steps.filter.outputs.website }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Check changed paths
id: filter
uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
with:
filters: |
code:
- '**'
- '!docs/**'
- '!website/**'
- '!README.md'
docs:
- 'docs/**'
website:
- 'website/**'
lint:
name: Lint
runs-on: ubuntu-24.04
needs: [changed-paths]
if: github.event_name == 'workflow_dispatch' || needs.changed-paths.outputs.code == 'true'
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Install linters
run: |
go install honnef.co/go/tools/cmd/staticcheck@v0.7.0
- name: Run go fmt check
run: |
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
echo "Code is not formatted. Run 'go fmt ./...'"
gofmt -s -d .
exit 1
fi
- name: Run go vet
run: go vet ./...
- name: Run staticcheck
run: staticcheck ./...
test:
name: Unit + Integration Tests
runs-on: ubuntu-24.04
needs: [changed-paths]
if: github.event_name == 'workflow_dispatch' || needs.changed-paths.outputs.code == 'true'
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Install envtest
run: |
export KUBEBUILDER_ASSETS=$(go run sigs.k8s.io/controller-runtime/tools/setup-envtest@v0.24.0 use -p path)
echo "KUBEBUILDER_ASSETS=$KUBEBUILDER_ASSETS" >> $GITHUB_ENV
- name: Run unit tests with coverage
run: |
module_path="$(go list -m)"
go test -v -race -coverprofile=unit.out \
$(go list ./... | grep -v -x "${module_path}/test/integration")
- name: Test Sentinel service modules (api, ui)
run: |
(cd services/api && go test -v -race -count=1 ./...)
(cd services/ui && go test -v -race -count=1 ./...)
- name: Run integration tests with coverage
run: |
go test -v -race -timeout 30m -coverprofile=integration.out \
-coverpkg=./internal/...,./pkg/...,./api/... \
./test/integration/...
env:
KUBEBUILDER_ASSETS: ${{ env.KUBEBUILDER_ASSETS }}
- name: Merge coverage files
run: |
# Remove mode line from integration.out and append to unit.out
tail -n +2 integration.out >> unit.out
mv unit.out coverage.out
- name: Upload coverage
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
files: ./coverage.out
flags: pre-merge
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
e2e-kind:
name: Kind E2E
runs-on: ubuntu-24.04
needs: [changed-paths, lint, test]
if: github.event_name == 'workflow_dispatch' || needs.changed-paths.outputs.code == 'true'
timeout-minutes: 90
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Free runner disk space
run: |
df -h
sudo rm -rf \
/opt/ghc \
/opt/hostedtoolcache/CodeQL \
/usr/local/lib/android \
/usr/local/share/boost \
/usr/share/dotnet
docker system prune -af --volumes || true
df -h
- name: Validate E2E scenario selectors
run: bash test/e2e/scenarios_test.sh
- name: Install kubectl
run: |
curl -fsSL -o kubectl "https://dl.k8s.io/release/v1.34.1/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/kubectl
- name: Install kind
run: |
curl -fsSL -o kind "https://kind.sigs.k8s.io/dl/v0.30.0/kind-linux-amd64"
chmod +x kind
sudo mv kind /usr/local/bin/kind
- name: Install E2E dependencies
run: |
sudo apt-get update
sudo apt-get install -y python3 ripgrep
- name: Show tool versions
run: |
docker version
kubectl version --client
kind version
python3 --version
rg --version
- name: Validate OpenAI API key
id: openai-key
if: github.actor != 'dependabot[bot]'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "valid=false" >> "$GITHUB_OUTPUT"
echo "::warning::OPENAI_API_KEY is not configured; skipping Kind e2e for this non-Dependabot run."
exit 0
fi
status="$(curl -sS -o /tmp/openai-key-check.json -w "%{http_code}" \
"https://api.openai.com/v1/models" \
-H "Authorization: Bearer ${OPENAI_API_KEY}" || true)"
if [ "${status}" = "200" ]; then
echo "valid=true" >> "$GITHUB_OUTPUT"
echo "OpenAI API key was accepted by /v1/models."
else
echo "valid=false" >> "$GITHUB_OUTPUT"
echo "::warning::OpenAI API key validation failed with HTTP ${status}; skipping Kind e2e."
fi
- name: Run kind e2e
if: github.actor == 'dependabot[bot]' || steps.openai-key.outputs.valid == 'true'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
E2E_ARTIFACT_DIR: ${{ github.workspace }}/.e2e-artifacts/kind
E2E_SCENARIOS: ${{ github.actor == 'dependabot[bot]' && 'smoke-auth,governance' || 'all' }}
run: bash test/e2e/kind.sh
- name: Report skipped Kind e2e
if: github.actor != 'dependabot[bot]' && steps.openai-key.outputs.valid != 'true'
run: echo "::notice::Kind e2e was skipped because OPENAI_API_KEY was missing or rejected by OpenAI."
- name: Upload e2e artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: kind-e2e-artifacts
path: .e2e-artifacts/kind
if-no-files-found: ignore
benchmark:
name: Benchmarks
runs-on: ubuntu-24.04
needs: [changed-paths]
if: github.event_name == 'workflow_dispatch' || needs.changed-paths.outputs.code == 'true'
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Run benchmarks
run: |
go test -run=^$ -bench=. -benchmem -count=1 ./test/benchmark/...
generated-drift:
name: Generated File Drift
runs-on: ubuntu-24.04
needs: [changed-paths]
if: github.event_name == 'workflow_dispatch' || needs.changed-paths.outputs.code == 'true' || needs.changed-paths.outputs.docs == 'true'
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Regenerate CRDs/manifests
run: make -f Makefile.operator generate manifests
- name: Regenerate Go package reference
run: python3 docs/scripts/generate_go_package_reference.py
- name: Check for drift
run: |
if ! git diff --quiet; then
echo "Generated files are out of date. Run 'make -f Makefile.operator generate manifests' and 'python3 docs/scripts/generate_go_package_reference.py'."
git diff
exit 1
fi
sbom:
name: SBOM
runs-on: ubuntu-24.04
needs: [changed-paths]
if: github.event_name == 'workflow_dispatch' || needs.changed-paths.outputs.code == 'true'
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Generate repository SBOM
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
path: .
format: spdx-json
output-file: mcp-runtime-repository.spdx.json
- name: Upload repository SBOM
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mcp-runtime-repository-sbom
path: mcp-runtime-repository.spdx.json
if-no-files-found: error
deploy-docs:
name: Deploy Docs (docs.mcpruntime.org)
runs-on: ubuntu-24.04
concurrency:
group: deploy-docs-${{ github.ref }}
cancel-in-progress: true
needs: [changed-paths]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changed-paths.outputs.docs == 'true'
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Validate docs deploy secrets
env:
DOCS_DEPLOY_HOST: ${{ secrets.DOCS_DEPLOY_HOST }}
DOCS_DEPLOY_USER: ${{ secrets.DOCS_DEPLOY_USER }}
DOCS_DEPLOY_PATH: ${{ secrets.DOCS_DEPLOY_PATH }}
DOCS_DEPLOY_SSH_KEY: ${{ secrets.DOCS_DEPLOY_SSH_KEY }}
DOCS_DEPLOY_HOST_KEY: ${{ secrets.DOCS_DEPLOY_HOST_KEY }}
run: |
test -n "$DOCS_DEPLOY_HOST"
test -n "$DOCS_DEPLOY_USER"
test -n "$DOCS_DEPLOY_PATH"
test -n "$DOCS_DEPLOY_SSH_KEY"
- name: Set up SSH key
env:
DOCS_DEPLOY_HOST: ${{ secrets.DOCS_DEPLOY_HOST }}
DOCS_DEPLOY_SSH_KEY: ${{ secrets.DOCS_DEPLOY_SSH_KEY }}
DOCS_DEPLOY_HOST_KEY: ${{ secrets.DOCS_DEPLOY_HOST_KEY }}
run: |
install -m 700 -d ~/.ssh
printf "%s\n" "$DOCS_DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
docs_host="${DOCS_DEPLOY_HOST//$'\r'/}"
docs_host="${docs_host#"${docs_host%%[![:space:]]*}"}"
docs_host="${docs_host%"${docs_host##*[![:space:]]}"}"
printf "DOCS_DEPLOY_HOST_CLEAN=%s\n" "$docs_host" >> "$GITHUB_ENV"
docs_known_hosts="$(mktemp)"
docs_known_host="${DOCS_DEPLOY_HOST_KEY//$'\r'/}"
if [ -n "$docs_known_host" ]; then
case "$docs_known_host" in
ssh-*|ecdsa-*) printf "%s %s\n" "$docs_host" "$docs_known_host" > "$docs_known_hosts" ;;
*" ssh-"*|*" ecdsa-"*) printf "%s\n" "$docs_known_host" > "$docs_known_hosts" ;;
*) echo "::warning::Ignoring DOCS_DEPLOY_HOST_KEY because it is not a known_hosts entry or bare SSH host key." ;;
esac
fi
if ! ssh-keygen -F "$docs_host" -f "$docs_known_hosts" >/dev/null 2>&1; then
ssh-keyscan -T 10 -t ed25519,rsa "$docs_host" > "$docs_known_hosts"
fi
mv "$docs_known_hosts" ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
if ! ssh-keygen -F "$docs_host" -f ~/.ssh/known_hosts >/dev/null; then
echo "::error::Could not create a known_hosts entry for DOCS_DEPLOY_HOST."
exit 1
fi
- name: Verify docs SSH access
run: |
ssh \
-i "$HOME/.ssh/id_ed25519" \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
"${{ secrets.DOCS_DEPLOY_USER }}@$DOCS_DEPLOY_HOST_CLEAN" \
"true"
- name: Sync docs/
run: |
rsync -az --delete \
-e "ssh -i $HOME/.ssh/id_ed25519 -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts" \
docs/ \
"${{ secrets.DOCS_DEPLOY_USER }}@$DOCS_DEPLOY_HOST_CLEAN:${{ secrets.DOCS_DEPLOY_PATH }}/"
- name: Deploy docs container on remote host
env:
DOCS_DEPLOY_COMMAND: ${{ secrets.DOCS_DEPLOY_COMMAND }}
DOCS_DEPLOY_PATH: ${{ secrets.DOCS_DEPLOY_PATH }}
DOCS_CONTAINER_NAME: ${{ secrets.DOCS_CONTAINER_NAME }}
DOCS_IMAGE_NAME: ${{ secrets.DOCS_IMAGE_NAME }}
DOCS_HOST_PORT: ${{ secrets.DOCS_HOST_PORT }}
DOCS_CONTAINER_PORT: ${{ secrets.DOCS_CONTAINER_PORT }}
run: |
if [ -n "$DOCS_DEPLOY_COMMAND" ]; then
ssh "${{ secrets.DOCS_DEPLOY_USER }}@$DOCS_DEPLOY_HOST_CLEAN" "$DOCS_DEPLOY_COMMAND"
exit 0
fi
container_name="${DOCS_CONTAINER_NAME:-mcp-runtime-docs}"
image_name="${DOCS_IMAGE_NAME:-mcp-runtime-docs:latest}"
host_port="${DOCS_HOST_PORT:-8081}"
container_port="${DOCS_CONTAINER_PORT:-80}"
b64() { printf '%s' "$1" | base64 -w 0; }
# shellcheck disable=SC2029
ssh "${{ secrets.DOCS_DEPLOY_USER }}@$DOCS_DEPLOY_HOST_CLEAN" \
"DOCS_DEPLOY_PATH_B64=$(b64 "$DOCS_DEPLOY_PATH") \
DOCS_CONTAINER_NAME_B64=$(b64 "$container_name") \
DOCS_IMAGE_NAME_B64=$(b64 "$image_name") \
DOCS_HOST_PORT_B64=$(b64 "$host_port") \
DOCS_CONTAINER_PORT_B64=$(b64 "$container_port") \
bash -s" <<'REMOTE'
set -euo pipefail
decode() { printf '%s' "$1" | base64 -d; }
deploy_path="$(decode "$DOCS_DEPLOY_PATH_B64")"
container_name="$(decode "$DOCS_CONTAINER_NAME_B64")"
image_name="$(decode "$DOCS_IMAGE_NAME_B64")"
host_port="$(decode "$DOCS_HOST_PORT_B64")"
container_port="$(decode "$DOCS_CONTAINER_PORT_B64")"
cd "$deploy_path"
docker rm -f "$container_name" >/dev/null 2>&1 || true
docker build --pull -t "$image_name" .
docker run -d --name "$container_name" \
--restart unless-stopped \
-p "$host_port:$container_port" \
"$image_name"
docker image prune -f >/dev/null
REMOTE
deploy-website:
name: Deploy Website (mcpruntime.org)
runs-on: ubuntu-24.04
concurrency:
group: deploy-website-${{ github.ref }}
cancel-in-progress: true
needs: [changed-paths]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changed-paths.outputs.website == 'true'
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Validate website deploy secrets
env:
WEBSITE_DEPLOY_HOST: ${{ secrets.WEBSITE_DEPLOY_HOST }}
WEBSITE_DEPLOY_USER: ${{ secrets.WEBSITE_DEPLOY_USER }}
WEBSITE_DEPLOY_PATH: ${{ secrets.WEBSITE_DEPLOY_PATH }}
WEBSITE_DEPLOY_SSH_KEY: ${{ secrets.WEBSITE_DEPLOY_SSH_KEY }}
WEBSITE_DEPLOY_HOST_KEY: ${{ secrets.WEBSITE_DEPLOY_HOST_KEY }}
WEBSITE_BASE_URL: ${{ secrets.WEBSITE_BASE_URL }}
run: |
test -n "$WEBSITE_DEPLOY_HOST"
test -n "$WEBSITE_DEPLOY_USER"
test -n "$WEBSITE_DEPLOY_PATH"
test -n "$WEBSITE_DEPLOY_SSH_KEY"
- name: Set up SSH key
env:
WEBSITE_DEPLOY_HOST: ${{ secrets.WEBSITE_DEPLOY_HOST }}
WEBSITE_DEPLOY_SSH_KEY: ${{ secrets.WEBSITE_DEPLOY_SSH_KEY }}
WEBSITE_DEPLOY_HOST_KEY: ${{ secrets.WEBSITE_DEPLOY_HOST_KEY }}
run: |
install -m 700 -d ~/.ssh
printf "%s\n" "$WEBSITE_DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
website_host="${WEBSITE_DEPLOY_HOST//$'\r'/}"
website_host="${website_host#"${website_host%%[![:space:]]*}"}"
website_host="${website_host%"${website_host##*[![:space:]]}"}"
printf "WEBSITE_DEPLOY_HOST_CLEAN=%s\n" "$website_host" >> "$GITHUB_ENV"
website_known_hosts="$(mktemp)"
website_known_host="${WEBSITE_DEPLOY_HOST_KEY//$'\r'/}"
if [ -n "$website_known_host" ]; then
case "$website_known_host" in
ssh-*|ecdsa-*) printf "%s %s\n" "$website_host" "$website_known_host" > "$website_known_hosts" ;;
*" ssh-"*|*" ecdsa-"*) printf "%s\n" "$website_known_host" > "$website_known_hosts" ;;
*) echo "::warning::Ignoring WEBSITE_DEPLOY_HOST_KEY because it is not a known_hosts entry or bare SSH host key." ;;
esac
fi
if ! ssh-keygen -F "$website_host" -f "$website_known_hosts" >/dev/null 2>&1; then
ssh-keyscan -T 10 -t ed25519,rsa "$website_host" > "$website_known_hosts"
fi
mv "$website_known_hosts" ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
if ! ssh-keygen -F "$website_host" -f ~/.ssh/known_hosts >/dev/null; then
echo "::error::Could not create a known_hosts entry for WEBSITE_DEPLOY_HOST."
exit 1
fi
- name: Verify website SSH access
run: |
ssh \
-i "$HOME/.ssh/id_ed25519" \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
"${{ secrets.WEBSITE_DEPLOY_USER }}@$WEBSITE_DEPLOY_HOST_CLEAN" \
"true"
- name: Sync website/
run: |
rsync -az --delete \
-e "ssh -i $HOME/.ssh/id_ed25519 -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts" \
website/ \
"${{ secrets.WEBSITE_DEPLOY_USER }}@$WEBSITE_DEPLOY_HOST_CLEAN:${{ secrets.WEBSITE_DEPLOY_PATH }}/"
- name: Deploy website container on remote host
env:
WEBSITE_DEPLOY_COMMAND: ${{ secrets.WEBSITE_DEPLOY_COMMAND }}
WEBSITE_DEPLOY_PATH: ${{ secrets.WEBSITE_DEPLOY_PATH }}
WEBSITE_DOCS_URL: ${{ secrets.WEBSITE_DOCS_URL }}
WEBSITE_BASE_URL: ${{ secrets.WEBSITE_BASE_URL }}
WEBSITE_CONTAINER_NAME: ${{ secrets.WEBSITE_CONTAINER_NAME }}
WEBSITE_IMAGE_NAME: ${{ secrets.WEBSITE_IMAGE_NAME }}
WEBSITE_HOST_PORT: ${{ secrets.WEBSITE_HOST_PORT }}
WEBSITE_CONTAINER_PORT: ${{ secrets.WEBSITE_CONTAINER_PORT }}
run: |
if [ -n "$WEBSITE_DEPLOY_COMMAND" ]; then
ssh "${{ secrets.WEBSITE_DEPLOY_USER }}@$WEBSITE_DEPLOY_HOST_CLEAN" "$WEBSITE_DEPLOY_COMMAND"
exit 0
fi
container_name="${WEBSITE_CONTAINER_NAME:-mcp-runtime-website}"
image_name="${WEBSITE_IMAGE_NAME:-mcp-runtime-website:latest}"
host_port="${WEBSITE_HOST_PORT:-8080}"
container_port="${WEBSITE_CONTAINER_PORT:-8080}"
docs_url="${WEBSITE_DOCS_URL:-https://docs.mcpruntime.org/}"
base_url="${WEBSITE_BASE_URL:-https://mcpruntime.org}"
git_sha="${GITHUB_SHA:-unknown}"
b64() { printf '%s' "$1" | base64 -w 0; }
# shellcheck disable=SC2029
ssh "${{ secrets.WEBSITE_DEPLOY_USER }}@$WEBSITE_DEPLOY_HOST_CLEAN" \
"WEBSITE_DEPLOY_PATH_B64=$(b64 "$WEBSITE_DEPLOY_PATH") \
WEBSITE_CONTAINER_NAME_B64=$(b64 "$container_name") \
WEBSITE_IMAGE_NAME_B64=$(b64 "$image_name") \
WEBSITE_HOST_PORT_B64=$(b64 "$host_port") \
WEBSITE_CONTAINER_PORT_B64=$(b64 "$container_port") \
WEBSITE_DOCS_URL_B64=$(b64 "$docs_url") \
WEBSITE_BASE_URL_B64=$(b64 "$base_url") \
WEBSITE_GIT_SHA_B64=$(b64 "$git_sha") \
bash -s" <<'REMOTE'
set -euo pipefail
decode() { printf '%s' "$1" | base64 -d; }
deploy_path="$(decode "$WEBSITE_DEPLOY_PATH_B64")"
container_name="$(decode "$WEBSITE_CONTAINER_NAME_B64")"
image_name="$(decode "$WEBSITE_IMAGE_NAME_B64")"
host_port="$(decode "$WEBSITE_HOST_PORT_B64")"
container_port="$(decode "$WEBSITE_CONTAINER_PORT_B64")"
docs_url="$(decode "$WEBSITE_DOCS_URL_B64")"
base_url="$(decode "$WEBSITE_BASE_URL_B64")"
git_sha="$(decode "$WEBSITE_GIT_SHA_B64")"
cd "$deploy_path"
docker rm -f "$container_name" >/dev/null 2>&1 || true
docker build --pull --build-arg GIT_SHA="$git_sha" -t "$image_name" .
docker run -d --name "$container_name" \
--restart unless-stopped \
-p "$host_port:$container_port" \
-e MCP_DOCS_URL="$docs_url" \
-e MCP_WEBSITE_BASE_URL="$base_url" \
"$image_name"
docker image prune -f >/dev/null
REMOTE