Two CI/CD Platforms, One Microsoft
Microsoft owns both Azure DevOps and GitHub. They've been saying for years that the two products will converge. They haven't. In 2025, they're still distinct platforms with different strengths, different weaknesses, and different pricing models. Enterprises running on Azure need to pick one as their primary CI/CD platform, or figure out how to run both without doubling their operational burden.
I've managed CI/CD at organizations standardized on each. The decision isn't primarily technical -- it's about how your organization works, who needs access, and what your deployment governance requirements look like.
Azure DevOps Pipelines: The Enterprise Workhorse
Azure DevOps has been around since Team Foundation Server days. The UI feels dated in places, the YAML has quirks that trip up newcomers, and the documentation assumes you've been using Microsoft tools since 2010. But it handles enterprise requirements that GitHub Actions still doesn't match.
Environments and Approval Gates
Azure DevOps has first-class deployment environments with multi-layer approval gates, business hours restrictions, and exclusive locks. "Deploy to production only Tuesday through Thursday between 9 AM and 4 PM, after two designated approvers sign off, and only if no other deployment is running" -- that's a configuration screen, not custom code.
stages:
- stage: DeployStaging
jobs:
- deployment: DeployToStaging
environment: staging
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: 'Staging-Connection'
appName: 'api-staging'
- stage: DeployProduction
dependsOn: DeployStaging
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployToProd
environment: production
strategy:
canary:
increments: [10, 50]
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: 'Production-Connection'
appName: 'api-production'
deployToSlotOrASE: true
slotName: 'canary'
on:
failure:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: 'Production-Connection'
appName: 'api-production'
deployToSlotOrASE: true
slotName: 'previous'
routeTraffic:
steps:
- task: AzureAppServiceManage@0
inputs:
action: 'Swap Slots'
sourceSlot: 'canary'
That canary deployment with progressive traffic shifting and automatic rollback on failure is built into the pipeline syntax. In GitHub Actions, you'd implement the same flow yourself with shell scripts, conditional steps, and manual Azure CLI calls. It works, but it's more code to maintain and more opportunity for subtle bugs in deployment logic.
Service Connections and RBAC
Azure DevOps integrates tightly with Azure Active Directory. Service connections use managed identities with fine-grained RBAC. You can restrict which pipelines are allowed to use which service connections -- a feature branch pipeline can deploy to dev but physically cannot reference the production service connection.
# Service connection with pipeline restrictions
resource "azuredevops_serviceendpoint_azurerm" "production" {
project_id = azuredevops_project.main.id
service_endpoint_name = "Production-ARM"
description = "Production Azure subscription"
credentials {
serviceprincipalid = var.prod_sp_id
serviceprincipalkey = var.prod_sp_key
}
azurerm_spn_tenantid = var.tenant_id
azurerm_subscription_id = var.prod_subscription_id
azurerm_subscription_name = "Production"
}
resource "azuredevops_pipeline_authorization" "prod_restricted" {
project_id = azuredevops_project.main.id
resource_id = azuredevops_serviceendpoint_azurerm.production.id
type = "endpoint"
pipeline_id = azuredevops_build_definition.release_pipeline.id
}
Only the release pipeline can use the production service connection. Pull request validation pipelines, feature branch builds, and ad-hoc test runs can't accidentally touch production resources.
GitHub Actions: Developer-First Flexibility
GitHub Actions is younger, more flexible, and built around developer experience. The marketplace has thousands of community actions. Workflow syntax is cleaner and closer to how developers think about automation. But enterprise governance requires more custom work.
Workflow Syntax and Composition
GitHub workflows feel natural to developers because they're structured around events and jobs rather than stages and environments:
name: Deploy API
on:
push:
branches: [main]
workflow_dispatch:
inputs:
environment:
type: environment
required: true
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0'
- run: dotnet build --configuration Release
- run: dotnet test --no-build --verbosity normal
- uses: actions/upload-artifact@v4
with:
name: published-app
path: ./publish/
deploy-production:
needs: build-and-test
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
contents: read
steps:
- uses: actions/download-artifact@v4
with:
name: published-app
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: azure/webapps-deploy@v3
with:
app-name: api-production
Reusable Workflows
GitHub's strongest enterprise feature: reusable workflows with typed inputs and secret inheritance. Your platform team defines a standard deployment workflow, and application teams call it. This enforces deployment standards without restricting how teams build their applications.
# Platform team's shared workflow: .github/workflows/standard-deploy.yml
name: Standard Azure Deployment
on:
workflow_call:
inputs:
app-name:
required: true
type: string
environment:
required: true
type: string
health-check-path:
required: false
type: string
default: '/healthz'
secrets:
AZURE_CLIENT_ID:
required: true
AZURE_TENANT_ID:
required: true
AZURE_SUBSCRIPTION_ID:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: azure/webapps-deploy@v3
with:
app-name: ${{ inputs.app-name }}
- name: Health check
run: |
for i in $(seq 1 30); do
status=$(curl -s -o /dev/null -w '%{http_code}' \
"https://${{ inputs.app-name }}.azurewebsites.net${{ inputs.health-check-path }}")
if [ "$status" = "200" ]; then exit 0; fi
sleep 10
done
exit 1
# Application team calls the shared workflow
jobs:
deploy:
uses: org/platform-workflows/.github/workflows/standard-deploy.yml@v2
with:
app-name: my-api
environment: production
health-check-path: '/api/health'
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
Platform teams control deployment standards, health checks, and rollback procedures. Application teams own their build steps, test configurations, and deployment triggers. Neither blocks the other.
Self-Hosted Runners
Both platforms support self-hosted runners for builds needing private network access. GitHub has a meaningful advantage with ephemeral runners -- single-use runners that pick up one job and terminate. This eliminates the security risk of persistent agents that accumulate cached credentials, build artifacts, and potentially sensitive data from previous jobs.
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: ephemeral-runners
spec:
replicas: 5
template:
spec:
repository: org/repo
ephemeral: true
labels:
- self-hosted
- linux
- x64
resources:
requests:
cpu: "2"
memory: "4Gi"
Azure DevOps scale set agents provide similar functionality but require more configuration. The agent pool management is mature but feels like infrastructure management rather than a feature that just works.
Cost Comparison at Enterprise Scale
Azure DevOps: one free parallel job with 1,800 minutes per month. Each additional parallel job costs $40/month for Microsoft-hosted agents. GitHub Actions: 2,000 free minutes for private repos, then $0.008/minute for Linux runners.
For an enterprise with 500 builds per month averaging 8 minutes each: Azure DevOps costs about $80/month (two parallel jobs). GitHub Actions costs about $32/month. For 2,000 builds per month: Azure DevOps costs $120-$160/month (three to four parallel jobs). GitHub Actions costs about $128/month.
Neither cost is significant enough to drive the decision for an enterprise. The CI/CD platform choice should be about organizational fit, developer productivity, and governance requirements -- not saving $50/month on build minutes. Pick the platform that matches how your teams work, not the one with the cheaper price sheet.