Back to Blog
5 min read

Azure DevOps vs GitHub Actions: Choosing the Right CI/CD Platform

Both Azure DevOps Pipelines and GitHub Actions are excellent CI/CD platforms. Let’s compare them to help you choose the right tool for your projects.

Quick Comparison

FeatureAzure DevOpsGitHub Actions
YAML Configazure-pipelines.yml.github/workflows/*.yml
Hosted RunnersMicrosoft-hostedGitHub-hosted
Self-hostedYesYes
MarketplaceVisual Studio MarketplaceGitHub Marketplace
ArtifactsAzure ArtifactsGitHub Packages
IntegrationAzure servicesGitHub ecosystem

Build Pipeline Examples

Azure DevOps Pipeline

# azure-pipelines.yml
trigger:
  branches:
    include:
      - main
      - release/*
  paths:
    exclude:
      - docs/*
      - README.md

pool:
  vmImage: 'ubuntu-latest'

variables:
  buildConfiguration: 'Release'
  dotnetVersion: '7.0.x'

stages:
  - stage: Build
    displayName: 'Build and Test'
    jobs:
      - job: BuildJob
        steps:
          - task: UseDotNet@2
            displayName: 'Install .NET SDK'
            inputs:
              version: $(dotnetVersion)

          - task: DotNetCoreCLI@2
            displayName: 'Restore packages'
            inputs:
              command: restore
              projects: '**/*.csproj'

          - task: DotNetCoreCLI@2
            displayName: 'Build solution'
            inputs:
              command: build
              projects: '**/*.csproj'
              arguments: '--configuration $(buildConfiguration) --no-restore'

          - task: DotNetCoreCLI@2
            displayName: 'Run tests'
            inputs:
              command: test
              projects: '**/*Tests.csproj'
              arguments: '--configuration $(buildConfiguration) --collect:"XPlat Code Coverage"'

          - task: PublishCodeCoverageResults@1
            displayName: 'Publish code coverage'
            inputs:
              codeCoverageTool: 'Cobertura'
              summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'

          - task: DotNetCoreCLI@2
            displayName: 'Publish artifacts'
            inputs:
              command: publish
              publishWebProjects: true
              arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)'

          - task: PublishBuildArtifacts@1
            displayName: 'Upload artifacts'
            inputs:
              pathToPublish: '$(Build.ArtifactStagingDirectory)'
              artifactName: 'webapp'

  - stage: DeployDev
    displayName: 'Deploy to Development'
    dependsOn: Build
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: DeployWebApp
        environment: 'Development'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: 'Azure-Connection'
                    appName: 'webapp-dev'
                    package: '$(Pipeline.Workspace)/webapp/**/*.zip'

GitHub Actions Workflow

# .github/workflows/build-deploy.yml
name: Build and Deploy

on:
  push:
    branches:
      - main
      - 'release/*'
    paths-ignore:
      - 'docs/**'
      - 'README.md'
  pull_request:
    branches:
      - main

env:
  BUILD_CONFIGURATION: Release
  DOTNET_VERSION: '7.0.x'

jobs:
  build:
    name: Build and Test
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup .NET
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Restore dependencies
        run: dotnet restore

      - name: Build
        run: dotnet build --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore

      - name: Test
        run: dotnet test --configuration ${{ env.BUILD_CONFIGURATION }} --no-build --collect:"XPlat Code Coverage"

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: '**/coverage.cobertura.xml'

      - name: Publish
        run: dotnet publish --configuration ${{ env.BUILD_CONFIGURATION }} --output ./publish

      - name: Upload artifact
        uses: actions/upload-artifact@v3
        with:
          name: webapp
          path: ./publish

  deploy-dev:
    name: Deploy to Development
    needs: build
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: Development

    steps:
      - name: Download artifact
        uses: actions/download-artifact@v3
        with:
          name: webapp
          path: ./webapp

      - name: Login to Azure
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: Deploy to Azure Web App
        uses: azure/webapps-deploy@v2
        with:
          app-name: 'webapp-dev'
          package: ./webapp

Matrix Builds

Azure DevOps

strategy:
  matrix:
    linux:
      vmImage: 'ubuntu-latest'
    windows:
      vmImage: 'windows-latest'
    mac:
      vmImage: 'macOS-latest'

pool:
  vmImage: $(vmImage)

steps:
  - script: dotnet test
    displayName: 'Run tests on $(vmImage)'

GitHub Actions

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        dotnet-version: ['6.0.x', '7.0.x']

    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v3

      - uses: actions/setup-dotnet@v3
        with:
          dotnet-version: ${{ matrix.dotnet-version }}

      - run: dotnet test

Environment Protection

Azure DevOps

# Environments defined in Azure DevOps UI
stages:
  - stage: Production
    jobs:
      - deployment: Deploy
        environment: 'Production'  # Has approval gates configured
        strategy:
          runOnce:
            deploy:
              steps:
                - script: echo "Deploying to production"

GitHub Actions

jobs:
  deploy-prod:
    runs-on: ubuntu-latest
    environment:
      name: Production
      url: https://myapp.azurewebsites.net

    steps:
      - name: Deploy
        run: echo "Deploying to production"
        # Environment has required reviewers configured in GitHub settings

Reusable Components

Azure DevOps Templates

# templates/dotnet-build.yml
parameters:
  - name: buildConfiguration
    type: string
    default: 'Release'
  - name: projects
    type: string
    default: '**/*.csproj'

steps:
  - task: DotNetCoreCLI@2
    displayName: 'Restore'
    inputs:
      command: restore
      projects: ${{ parameters.projects }}

  - task: DotNetCoreCLI@2
    displayName: 'Build'
    inputs:
      command: build
      projects: ${{ parameters.projects }}
      arguments: '--configuration ${{ parameters.buildConfiguration }}'

# Using the template
stages:
  - stage: Build
    jobs:
      - job: BuildJob
        steps:
          - template: templates/dotnet-build.yml
            parameters:
              buildConfiguration: 'Release'

GitHub Actions Reusable Workflows

# .github/workflows/reusable-dotnet-build.yml
name: Reusable .NET Build

on:
  workflow_call:
    inputs:
      build-configuration:
        type: string
        default: 'Release'
      dotnet-version:
        type: string
        default: '7.0.x'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - uses: actions/setup-dotnet@v3
        with:
          dotnet-version: ${{ inputs.dotnet-version }}

      - run: dotnet build --configuration ${{ inputs.build-configuration }}

# Using the reusable workflow
# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  call-build:
    uses: ./.github/workflows/reusable-dotnet-build.yml
    with:
      build-configuration: 'Release'
      dotnet-version: '7.0.x'

When to Choose Azure DevOps

  • Enterprise features: Advanced security, compliance, audit trails
  • Full ALM: Work items, repos, boards, test plans in one place
  • Complex pipelines: Multi-stage deployments with approvals
  • Azure integration: Native integration with Azure services
  • Existing investment: Already using Azure DevOps for other features

When to Choose GitHub Actions

  • Open source: Free for public repositories
  • GitHub-native: Code and CI/CD in one place
  • Community actions: Large marketplace of community actions
  • Simplicity: Easier to get started
  • Modern syntax: More intuitive YAML syntax

Hybrid Approach

You can use both together:

# GitHub Actions that triggers Azure DevOps
name: Trigger Azure Pipeline

on:
  push:
    branches: [main]

jobs:
  trigger:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger Azure Pipeline
        run: |
          curl -X POST \
            -H "Authorization: Basic ${{ secrets.AZURE_DEVOPS_PAT }}" \
            -H "Content-Type: application/json" \
            -d '{"definition": {"id": 1}}' \
            "https://dev.azure.com/org/project/_apis/build/builds?api-version=6.0"

Conclusion

Both platforms are excellent choices. Azure DevOps excels in enterprise scenarios with its full ALM suite, while GitHub Actions offers simplicity and a vibrant community ecosystem. Many organizations successfully use both - GitHub Actions for open-source projects and quick CI, Azure DevOps for complex enterprise deployments.

Resources

Michael John Peña

Michael John Peña

Senior Data Engineer based in Sydney. Writing about data, cloud, and technology.