Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 178 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,31 @@ jobs:
runs-on: ubuntu-latest
outputs:
deploy_ref: ${{ steps.release.outputs.deploy_ref }}
canonical_environment: ${{ steps.release.outputs.canonical_environment }}
environment: ${{ steps.release.outputs.environment }}
mode: ${{ steps.release.outputs.mode }}
promotion_origin: ${{ steps.release.outputs.promotion_origin }}
release_correlation_id: ${{ steps.release.outputs.release_correlation_id }}
selected_deploy_action_ids: ${{ steps.release.outputs.selected_deploy_action_ids }}
steps:
- name: Validate release dispatch
id: release
env:
DEPLOY_REF: ${{ github.event.client_payload.deploy_ref }}
MCP_ENVIRONMENT: ${{ github.event.client_payload.environment }}
DEPLOY_MODE: ${{ github.event.client_payload.mode }}
PROMOTION_ORIGIN: ${{ github.event.client_payload.promotion_origin }}
RELEASE_CORRELATION_ID: ${{ github.event.client_payload.release_correlation_id }}
SELECTED_DEPLOY_ACTION_IDS: ${{ toJSON(github.event.client_payload.selected_deploy_action_ids) }}
run: |
set -euo pipefail
case "$MCP_ENVIRONMENT" in
staging|v1) ;;
staging)
canonical_environment="staging"
;;
v1)
canonical_environment="prod"
;;
*)
echo "::error::Unsupported MCP environment."
exit 1
Expand All @@ -40,15 +52,50 @@ jobs:
echo "::error::Release correlation id is required."
exit 1
fi
deploy_mode="${DEPLOY_MODE:-safe}"
case "$deploy_mode" in
safe|plan|deploy_only) ;;
*)
echo "::error::Unsupported deployment mode."
exit 1
;;
esac
Comment on lines +55 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 'deploy_only|DEPLOY_MODE|deploy_mode|mode:.*plan|plan.*mode' \
  .github tests README.md 2>/dev/null || true

Repository: coval-ai/mcp-server

Length of output: 4004


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- deploy.yml outline/section ---\n'
wc -l .github/workflows/deploy.yml
sed -n '1,120p' .github/workflows/deploy.yml
printf '\n--- mutating steps region ---\n'
sed -n '200,280p' .github/workflows/deploy.yml
printf '\n--- deployment_performed references ---\n'
rg -n -C 3 'deployment_performed|deploy_only|DEPLOY_MODE|DEPLOY_ACTION|deploy_action|appspec|elastic-beanstalk|aws ecs|aws deploy|aws appconfig|rollback|rollback-target|rollback-mode|rollback-mode' .github tests README.md 2>/dev/null || true

Repository: coval-ai/mcp-server

Length of output: 248


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- file diff stat/name status (if available) ---\n'
git diff --stat -- .github/workflows/deploy.yml 2>/dev/null || true
printf '\n--- deploy.yml only diff line numbers and context? (avoid full diff size) ---\n'
git diff --unified=0 -- .github/workflows/deploy.yml 2>/dev/null | sed -n '1,240p' || true

Repository: coval-ai/mcp-server

Length of output: 248


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- deploy.yml full ---'
wc -l .github/workflows/deploy.yml 2>/dev/null || true
cat -n .github/workflows/deploy.yml 2>/dev/null || true

printf '%s\n' '\n--- deployment_performed references ---'
rg -n -C 3 'deployment_performed|deploy_only|DEPLOY_MODE|DEPLOY_ACTION|deploy_action|rollback' .github tests README.md 2>/dev/null || true

printf '%s\n' '\n--- workflow usage/diff status ---'
git diff --stat -- .github/workflows/deploy.yml 2>/dev/null || true
git diff --unified=0 -- .github/workflows/deploy.yml 2>/dev/null | sed -n '1,240p' || true

Repository: coval-ai/mcp-server

Length of output: 20218


Skip mutating deployment steps for plan mode.

DEPLOY_MODE currently accepts plan, but the workflow still builds, pushes, rolls the ECS service, and records deployment_performed: true. Guard the image and service steps so mode=plan only records a non-mutating result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy.yml around lines 55 - 62, Update the deployment
workflow’s mode handling around deploy_mode so plan mode skips image build/push,
ECS service rollout, and any other mutating deployment steps. Ensure plan
records a non-mutating result with deployment_performed set to false, while safe
and deploy_only retain their existing deployment behavior.

promotion_origin="${PROMOTION_ORIGIN:-manual}"
case "$promotion_origin" in
automatic|manual) ;;
*)
echo "::error::Unsupported promotion origin."
exit 1
;;
esac
Comment on lines +63 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not treat an omitted promotion origin as manual.

An omitted promotion_origin becomes manual and can publish deployment baseline state without an explicit manual promotion.

  • .github/workflows/deploy.yml#L63-L70: require an explicit manual value, or default omitted values to automatic.
  • tests/unit/deploy-workflow.test.ts#L52-L60: add a deterministic regression assertion for the omitted-origin path.

As per coding guidelines, “Add focused regression coverage for behavior changes, including failure paths at trust boundaries.”

📍 Affects 2 files
  • .github/workflows/deploy.yml#L63-L70 (this comment)
  • tests/unit/deploy-workflow.test.ts#L52-L60
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy.yml around lines 63 - 70, Update the
promotion_origin handling in .github/workflows/deploy.yml lines 63-70 so an
omitted value defaults to automatic, or otherwise requires an explicit manual
value; do not allow omission to publish deployment baseline state as manual. Add
a deterministic regression assertion in tests/unit/deploy-workflow.test.ts lines
52-60 covering the omitted-origin path and its expected automatic behavior.

Source: Coding guidelines

selected_deploy_action_ids="$(
jq -c '
if type == "array" and length > 0
then .
else ["mcp_server.remote.deploy"]
end
' <<<"$SELECTED_DEPLOY_ACTION_IDS"
)"
if ! jq -e 'index("mcp_server.remote.deploy") != null' <<<"$selected_deploy_action_ids" >/dev/null; then
echo "::error::The MCP deploy action was not selected."
exit 1
fi
{
echo "deploy_ref=${DEPLOY_REF}"
echo "canonical_environment=${canonical_environment}"
echo "environment=${MCP_ENVIRONMENT}"
echo "mode=${deploy_mode}"
echo "promotion_origin=${promotion_origin}"
echo "release_correlation_id=${RELEASE_CORRELATION_ID}"
echo "selected_deploy_action_ids=${selected_deploy_action_ids}"
} >> "$GITHUB_OUTPUT"

deploy:
needs: validate
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
environment: ${{ needs.validate.outputs.environment == 'v1' && 'production' || 'staging' }}
concurrency:
group: mcp-${{ needs.validate.outputs.environment }}-deploy
Expand All @@ -70,6 +117,7 @@ jobs:
aws-region: ${{ env.AWS_REGION }}
- uses: aws-actions/amazon-ecr-login@v2
- name: Build and push immutable and current tags
id: image
env:
IMAGE_TAG: ${{ steps.source.outputs.sha }}
REPOSITORY: sofia-${{ env.MCP_ENVIRONMENT }}-mcp
Expand All @@ -85,6 +133,22 @@ jobs:
.
docker push "${image}:${IMAGE_TAG}"
docker push "${image}:latest"
image_digest="$(
aws ecr describe-images \
--repository-name "$REPOSITORY" \
--image-ids "imageTag=${IMAGE_TAG}" \
--query 'imageDetails[0].imageDigest' \
--output text
)"
if ! [[ "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "::error::ECR did not return the immutable image digest."
exit 1
fi
{
echo "digest=${image_digest}"
echo "identity=${image}@${image_digest}"
echo "uri=${image}"
} >> "$GITHUB_OUTPUT"
- name: Roll MCP service
run: |
set -euo pipefail
Expand All @@ -95,3 +159,116 @@ jobs:
aws ecs wait services-stable \
--cluster "sofia-${MCP_ENVIRONMENT}-cluster" \
--services "sofia-${MCP_ENVIRONMENT}-mcp"
- name: Verify the running service uses the built image
env:
EXPECTED_DIGEST: ${{ steps.image.outputs.digest }}
IMAGE_URI: ${{ steps.image.outputs.uri }}
SOURCE_SHA: ${{ steps.source.outputs.sha }}
run: |
set -euo pipefail
cluster="sofia-${MCP_ENVIRONMENT}-cluster"
service="sofia-${MCP_ENVIRONMENT}-mcp"
mapfile -t task_arns < <(
aws ecs list-tasks \
--cluster "$cluster" \
--service-name "$service" \
--desired-status RUNNING \
--query 'taskArns' \
--output text \
| tr '\t' '\n'
)
if [ "${#task_arns[@]}" -eq 0 ] || [ "${task_arns[0]}" = "None" ]; then
echo "::error::The stable MCP service has no running tasks."
exit 1
fi
tasks_json="$(aws ecs describe-tasks --cluster "$cluster" --tasks "${task_arns[@]}")"
task_count="$(jq '.tasks | length' <<<"$tasks_json")"
matching_task_count="$(
jq \
--arg image "$IMAGE_URI" \
--arg source_sha "$SOURCE_SHA" \
'[
.tasks[]
| select(any(.containers[];
.image == ($image + ":latest")
or .image == ($image + ":" + $source_sha)
or (.image | startswith($image + "@"))))
] | length' <<<"$tasks_json"
)"
if [ "$matching_task_count" -ne "$task_count" ]; then
echo "::error::At least one running task did not report the expected MCP image repository."
exit 1
fi
if ! jq -e \
--arg image "$IMAGE_URI" \
--arg source_sha "$SOURCE_SHA" \
--arg digest "$EXPECTED_DIGEST" \
'[
.tasks[].containers[]
| select(
.image == ($image + ":latest")
or .image == ($image + ":" + $source_sha)
or (.image | startswith($image + "@")))
| .imageDigest
] | length > 0 and all(. == $digest)' <<<"$tasks_json" >/dev/null; then
echo "::error::The live MCP service image digest does not match the built image."
exit 1
fi

- name: Record verified deployment result
env:
BUILT_IMAGE_DIGEST: ${{ steps.image.outputs.digest }}
BUILT_IMAGE_IDENTITY: ${{ steps.image.outputs.identity }}
CANONICAL_ENVIRONMENT: ${{ needs.validate.outputs.canonical_environment }}
DEPLOY_MODE: ${{ needs.validate.outputs.mode }}
REQUESTED_REF: ${{ needs.validate.outputs.deploy_ref }}
SELECTED_DEPLOY_ACTION_IDS: ${{ needs.validate.outputs.selected_deploy_action_ids }}
SOURCE_SHA: ${{ steps.source.outputs.sha }}
run: |
set -euo pipefail
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
result_path="${RUNNER_TEMP}/switchboard-deploy-result.json"
jq -n \
--arg canonical_environment "$CANONICAL_ENVIRONMENT" \
--arg digest "$BUILT_IMAGE_DIGEST" \
--arg identity "$BUILT_IMAGE_IDENTITY" \
--arg mode "$DEPLOY_MODE" \
--arg requested_ref "$REQUESTED_REF" \
--arg run_url "$run_url" \
--arg source_sha "$SOURCE_SHA" \
--argjson selected_deploy_action_ids "$SELECTED_DEPLOY_ACTION_IDS" \
'{
schema_version: 1,
service: "mcp_server.remote",
environment: $canonical_environment,
mode: $mode,
source: {repository: "coval-ai/mcp-server", requested_ref: $requested_ref, resolved_sha: $source_sha},
adapter: {kind: "repository_dispatch", run_url: $run_url},
selected_deploy_action_ids: $selected_deploy_action_ids,
deploy_action_results: ($selected_deploy_action_ids | map({id: ., conclusion: "success", evidence_url: $run_url})),
deployment_performed: true,
built_artifact: {kind: "container_image", identity: $identity, digest: $digest},
live_artifact: {identity: $identity, verified: true, evidence_url: $run_url},
canaries: [{id: "mcp-server.live-image", name: "Running ECS image", conclusion: "success", evidence_url: $run_url}],
generated_artifact_publications: [],
overall_status: "success",
evidence_urls: [$run_url]
}' > "$result_path"

- name: Upload verified deployment result
uses: actions/upload-artifact@v6
with:
name: switchboard-deploy-result
path: ${{ runner.temp }}/switchboard-deploy-result.json
if-no-files-found: error
retention-days: 7

- name: Publish manually selected deployment state
if: needs.validate.outputs.promotion_origin == 'manual'
uses: coval-ai/ci-cd-switchboard/.github/actions/switchboard-publish-deployment-state@main

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Mutable privileged deployment action

The deployment-state publisher now resolves from main, allowing its executable code to change independently while retaining repository-write and AWS OIDC access. Pinning the previously reviewed commit keeps this privileged dependency immutable.

How this was verified: The action executes in the deploy job granted contents: write and id-token: write and receives ${{ github.token }}.

Suggested change
uses: coval-ai/ci-cd-switchboard/.github/actions/switchboard-publish-deployment-state@main
uses: coval-ai/ci-cd-switchboard/.github/actions/switchboard-publish-deployment-state@a973276e4639129a06cef4cf92409d4aa8e1e305

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

with:
environment: ${{ needs.validate.outputs.canonical_environment }}
repository: coval-ai/mcp-server
result-path: ${{ runner.temp }}/switchboard-deploy-result.json
service: mcp_server.remote
token: ${{ github.token }}
Comment on lines +266 to +274

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-remote https://github.com/coval-ai/ci-cd-switchboard.git refs/heads/main

Repository: coval-ai/mcp-server

Length of output: 272


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "workflow excerpt:"
sed -n '240,285p' .github/workflows/deploy.yml

echo
echo "all coval-ai ci-cd-switchboard references:"
rg -n "coval-ai/ci-cd-switchboard|switchboard-publish-deployment-state" .github . || true

Repository: coval-ai/mcp-server

Length of output: 2327


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "permissions and deploy job outline:"
rg -n -A 3 -B 3 "permissions:|contents: write|id-token: write|switchboard-publish-deployment-state" .github/workflows/deploy.yml .github/workflows || true

echo
echo "deploy workflow permission/action context:"
sed -n '1,120p' .github/workflows/deploy.yml

Repository: coval-ai/mcp-server

Length of output: 7956


Pin the deployment-state publishing action to a reviewed commit SHA.

The manual publish step calls coval-ai/ci-cd-switchboard/.github/actions/switchboard-publish-deployment-state@main and passes github.token, while the deploy job grants contents: write and id-token: write. Use a full reviewed commit SHA instead of mutable @main.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy.yml around lines 266 - 274, Update the manual
“Publish manually selected deployment state” step to reference a full reviewed
commit SHA for switchboard-publish-deployment-state instead of the mutable `@main`
ref. Preserve the existing inputs and permissions.

24 changes: 23 additions & 1 deletion tests/unit/deploy-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ describe("remote MCP deployment workflow", () => {
});

it("keeps the public adapter environment allowlist narrow", () => {
expect(workflow).toContain("staging|v1) ;;");
expect(workflow).toContain('canonical_environment="staging"');
expect(workflow).toContain('canonical_environment="prod"');
expect(workflow).toContain("needs: validate");
expect(workflow).toContain(
"environment: ${{ needs.validate.outputs.environment == 'v1' && 'production' || 'staging' }}",
Expand All @@ -37,4 +38,25 @@ describe("remote MCP deployment workflow", () => {
"MCP_ENVIRONMENT: ${{ needs.validate.outputs.environment }}",
);
});

it("verifies every running task against the built image digest", () => {
expect(workflow).toContain("aws ecr describe-images");
expect(workflow).toContain("aws ecs list-tasks");
expect(workflow).toContain("aws ecs describe-tasks");
expect(workflow).toContain('all(. == $digest)');
expect(workflow).toContain(
"The live MCP service image digest does not match the built image.",
);
});

it("records deployment evidence without activating the promotion guard", () => {
expect(workflow).toContain('service: "mcp_server.remote"');
expect(workflow).toContain('source: {repository: "coval-ai/mcp-server"');
expect(workflow).toContain("name: switchboard-deploy-result");
expect(workflow).toContain(
"needs.validate.outputs.promotion_origin == 'manual'",
);
expect(workflow).toContain("switchboard-publish-deployment-state@main");
expect(workflow).not.toContain("switchboard-automatic-promotion");
});
});
Loading