From 49268f3dbab98597bf3499f0830dd423c55bff37 Mon Sep 17 00:00:00 2001 From: Aaron Sachs <898627+asachs01@users.noreply.github.com> Date: Wed, 13 May 2026 15:27:26 -0400 Subject: [PATCH] feat(workflows): add mcp-server-deploy reusable workflow Canonical deploy workflow for wyre-technology/*-mcp repositories. Companion to mcp-server-ci.yml, finishing the per-repo workflow factorization (CI was already shared; deploy was still 8x copy-paste, which is how we ended up with autotask-mcp silently shipping every release into an orphaned ACA for 3+ weeks). What this workflow does: - Logs into Azure via OIDC - Deploys the caller's image to gwp- by IMMUTABLE digest - Hard-fails if the digest input is empty or not sha256:-prefixed - Sets IMAGE_VERSION env var on the new revision tying it back to release version + git SHA + workflow run ID for forensics Failure patterns this fixes for the fleet: 1. Deploying to mcpgw-prod- instead of gwp-. The gateway routes vendor traffic per VENDOR_URL_ env, all of which point to gwp-. The mcpgw-prod-* ACAs are orphaned legacy from a prior naming convention. Releases shipped to them are silent no-ops. 2. Deploying by :latest tag. :latest is mutable and can resolve to a stale digest through GHCR edge caches or ACA's image-pull cache, making deploys "succeed" while actually rolling onto the prior image (observed 2026-05-13 with autotask-mcp PRs #95 and #96). Caller contract (release.yml): - docker job must expose `outputs.digest: ${{ steps..outputs.digest }}` - deploy job replaces its inline body with `uses: wyre-technology/.github/.github/workflows/mcp-server-deploy.yml@` passing vendor-slug, image-name, digest, version. Secrets propagate via `secrets: inherit` or explicit forwarding. Will be rolled out to autotask-mcp first as the reference call site, then to halopsa-mcp, datto-rmm-mcp, itglue-mcp, huntress-mcp, liongard-mcp, domotz-mcp, cipp-mcp in follow-up PRs. See related skills: - ci-docker-semantic-release-version-race - mcp-gateway-troubleshooting --- .github/workflows/mcp-server-deploy.yml | 107 ++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 .github/workflows/mcp-server-deploy.yml diff --git a/.github/workflows/mcp-server-deploy.yml b/.github/workflows/mcp-server-deploy.yml new file mode 100644 index 0000000..2c31d2f --- /dev/null +++ b/.github/workflows/mcp-server-deploy.yml @@ -0,0 +1,107 @@ +name: MCP Server Deploy (reusable) + +# Canonical deploy workflow for wyre-technology/*-mcp repositories. +# Called from each per-repo `.github/workflows/release.yml` `deploy` job after +# semantic-release + docker push have produced a versioned image and its digest. +# +# What this workflow does: +# - Logs into Azure via OIDC +# - Deploys the image to `gwp-` in `mcp-gateway-prod` by IMMUTABLE +# digest (never :latest) +# - Sets an `IMAGE_VERSION` env var on the revision tying it back to release +# version + git SHA + workflow run ID +# +# Failure patterns this prevents: +# - Deploying to mcpgw-prod- (orphaned legacy ACAs from a prior naming +# convention — the gateway routes vendor traffic to gwp- per its +# VENDOR_URL_ env). Releases shipped to mcpgw-prod-* are silent no-ops. +# - Deploying by :latest tag, which can resolve to a stale digest through GHCR +# edge caches or ACA's image-pull layer — the deploy "succeeds" but the new +# revision actually rolls onto the prior image (observed in autotask-mcp on +# 2026-05-13, broken for ~3 weeks). +# +# Inputs are passed through `env:` before any shell interpolation, so no +# untrusted strings reach `run:` blocks (workflow_call inputs are typed and +# come from sibling repo workflows we control, but defense-in-depth is cheap). + +on: + workflow_call: + inputs: + vendor-slug: + description: 'Vendor slug. Drives the target ACA name `gwp-` and is the source of truth for where the gateway routes traffic.' + required: true + type: string + image-name: + description: 'Full GHCR image base path (no tag/digest), e.g. `ghcr.io/wyre-technology/autotask-mcp`.' + required: true + type: string + digest: + description: 'Image digest (e.g. `sha256:abc123...`) produced by the caller''s `docker/build-push-action` step. Pass via `${{ needs.docker.outputs.digest }}`.' + required: true + type: string + version: + description: 'Semver release version with no `v` prefix (e.g. `2.25.2`). Used only for legibility in the deployed revision''s IMAGE_VERSION env var.' + required: true + type: string + resource-group: + description: 'Azure resource group containing the target ACA.' + required: false + type: string + default: 'mcp-gateway-prod' + environment: + description: 'GitHub Environment to run in (for env-scoped secrets and deployment protection rules).' + required: false + type: string + default: 'production' + secrets: + AZURE_CLIENT_ID: + required: true + AZURE_TENANT_ID: + required: true + AZURE_SUBSCRIPTION_ID: + required: true + +jobs: + deploy: + name: Deploy ${{ inputs.vendor-slug }} to gwp-${{ inputs.vendor-slug }} + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + permissions: + id-token: write + contents: read + env: + TARGET_ACA: gwp-${{ inputs.vendor-slug }} + RESOURCE_GROUP: ${{ inputs.resource-group }} + IMAGE_NAME: ${{ inputs.image-name }} + IMAGE_DIGEST: ${{ inputs.digest }} + RELEASE_VERSION: ${{ inputs.version }} + steps: + - name: Azure login (OIDC) + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Deploy to Azure Container Apps + run: | + set -euo pipefail + + if [ -z "${IMAGE_DIGEST}" ]; then + echo "::error::Input 'digest' is empty. Pass the docker/build-push-action digest output (steps..outputs.digest) — refusing to deploy by mutable :latest tag." >&2 + exit 1 + fi + if [[ "${IMAGE_DIGEST}" != sha256:* ]]; then + echo "::error::Input 'digest' must start with 'sha256:' (got '${IMAGE_DIGEST}'). Refusing to deploy a non-digest reference." >&2 + exit 1 + fi + + SHORT_SHA="${GITHUB_SHA::7}" + IMAGE="${IMAGE_NAME}@${IMAGE_DIGEST}" + echo "Deploying ${IMAGE} to ${TARGET_ACA} (release v${RELEASE_VERSION}, sha ${SHORT_SHA}, run ${GITHUB_RUN_ID})" + + az containerapp update \ + --name "${TARGET_ACA}" \ + --resource-group "${RESOURCE_GROUP}" \ + --image "${IMAGE}" \ + --set-env-vars "IMAGE_VERSION=v${RELEASE_VERSION}-sha-${SHORT_SHA}-${GITHUB_RUN_ID}"