name: Deploy MCP Server (Container App) on: workflow_dispatch: permissions: id-token: write # OIDC federated login (secret-less) contents: read env: ACR_NAME: autopilotmonitoreuacr CONTAINER_APP_NAME: autopilotmonitor-mcp RESOURCE_GROUP: ${{ secrets.AZURE_MCP_RESOURCE_GROUP }} # Unique per RUN, not per commit: the docs corpus is baked into the image, so a # docs-only redeploy at an unchanged code sha must still produce a new image # reference — with a sha-only tag `az containerapp update` saw the same image # and silently created no revision (2026-08-28). IMAGE_TAG: ${{ github.sha }}-${{ github.run_number }} PACKAGE_JSON: src/McpServer/autopilot-monitor-mcp/package.json # Hosts (MCP, versions container) come from .github/scripts/Get-Endpoints.ps1. jobs: # One job on purpose, unlike the other deploys: all third-party code (npm ci, the build, the # embedding precompute) runs inside `docker build`, whose containers see neither the az # login nor the registry credentials nor this job's OIDC request token. That container is # the boundary -- never move npm onto the runner here, or the build gets the job's # credentials. The deploy identity trusts the `production` environment, which admits main only. build-and-deploy: runs-on: ubuntu-latest name: Build and Deploy MCP Server environment: production steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Load endpoints shell: pwsh run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv # The published customer documentation lives in its own PUBLIC repo and is # baked into the image as the search_docs corpus (see the Dockerfile). No # token is needed. Checking it out here rather than vendoring it keeps the # docs single-sourced — but it also means EDITING THE DOCS DOES NOT UPDATE A # RUNNING SERVER; this workflow must be re-run (IMAGE_TAG is per run so the # re-run actually rolls a new revision). /health reports the SHA captured # below so that staleness is checkable. - name: Check out documentation bundle uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false repository: autopilotmonitor/autopilotmonitor-docs path: mcp-docs - name: Resolve docs commit id: docs run: echo "sha=$(git -C mcp-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Login to Azure (OIDC) uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} # Same counter mechanism as agent and backend: reserve-before-build with # ETag-CAS against the component's own counter blob. major.minor stays curated # in package.json; only the build part is minted here. - name: Reserve build number (counter blob CAS) id: version shell: pwsh run: | $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "$env:VERSIONS_CONTAINER_URL/mcp.txt" $pkgVersion = (Get-Content ${{ env.PACKAGE_JSON }} -Raw | ConvertFrom-Json).version $prefix = ($pkgVersion -split '\.')[0..1] -join '.' $version = "$prefix.$buildNumber" $buildUtc = [DateTime]::UtcNow.ToString('o') Write-Host "MCP version: $version (built $buildUtc)" # 7 chars, matching what the backend's BackendBuildInfo reports — one commit # shape across all components so the values can be compared as-is. $shortSha = '${{ github.sha }}'.Substring(0, 7) "build_number=$buildNumber" >> $env:GITHUB_OUTPUT "version=$version" >> $env:GITHUB_OUTPUT "build_utc=$buildUtc" >> $env:GITHUB_OUTPUT "short_sha=$shortSha" >> $env:GITHUB_OUTPUT - name: Login to ACR run: az acr login --name "$ACR_NAME" - name: Build and push container image run: | docker build \ -f src/McpServer/autopilot-monitor-mcp/Dockerfile \ --build-arg DOCS_COMMIT=${{ steps.docs.outputs.sha }} \ --build-arg BUILD_NUMBER=${{ steps.version.outputs.build_number }} \ --build-arg BUILD_COMMIT=${{ steps.version.outputs.short_sha }} \ --build-arg BUILD_UTC=${{ steps.version.outputs.build_utc }} \ -t "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME:$IMAGE_TAG" \ -t "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME:latest" \ . docker push "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME" --all-tags - name: Deploy to Container App run: | az containerapp update \ --name "$CONTAINER_APP_NAME" \ --resource-group "$RESOURCE_GROUP" \ --image "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME:$IMAGE_TAG" # `az containerapp update` returns once the revision is provisioned, not once # it serves traffic — and with minReplicas=0 the first request also pays a cold # start. Polling /health until it reports THIS build is what makes the manifest # below mean "live"; a full 5 minutes because the cold start alone is ~25s. - name: Verify deployed build is live shell: pwsh run: | $expectedCommit = '${{ steps.version.outputs.short_sha }}' $expectedVersion = '${{ steps.version.outputs.version }}' $deadline = [DateTime]::UtcNow.AddMinutes(5) $attempt = 0 while ([DateTime]::UtcNow -lt $deadline) { $attempt++ try { $health = Invoke-RestMethod -Uri "$env:MCP_BASE_URL/health" -Method Get -TimeoutSec 60 if ($health.commit -eq $expectedCommit -and $health.version -eq $expectedVersion) { Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commit), docs $($health.docs.commit) ($($health.docs.chunks) chunks)" exit 0 } Write-Host "Attempt ${attempt}: serving $($health.version) @ $($health.commit) -- waiting for $expectedVersion @ $expectedCommit" } catch { Write-Host "Attempt ${attempt}: /health not answering yet -- $($_.Exception.Message)" } Start-Sleep -Seconds 15 } throw "MCP did not report $expectedVersion @ $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published" # Cross-component record of what is deployed where; written last and only on a # verified deploy. docsCommit rides along because for this component "which # build" and "which documentation" are two separate questions. - name: Publish version manifest shell: pwsh run: | $manifest = [ordered]@{ component = 'mcp' version = '${{ steps.version.outputs.version }}' commit = '${{ steps.version.outputs.short_sha }}' buildUtc = '${{ steps.version.outputs.build_utc }}' deployedUtc = [DateTime]::UtcNow.ToString('o') runId = '${{ github.run_id }}' docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7) } | ConvertTo-Json $authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1 $url = "$env:VERSIONS_CONTAINER_URL/mcp.json" # no-cache: rotating pointer, must never be served stale by any fronting cache. Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null Write-Host "Published versions/mcp.json" Write-Host $manifest