Skip to main content

Migrating from Azure DevOps to GitHub Actions: A Step-by-Step Guide

February 14, 2026 10 min read

Last year, our organization made the decision to consolidate on GitHub — moving our repositories, work items, and CI/CD pipelines from Azure DevOps. The code migration was straightforward (git is git), and the work item migration was tedious but manageable. The CI/CD pipeline migration, however, was where we spent 80% of our effort. Not because GitHub Actions is harder — it's actually more intuitive in many ways — but because Azure DevOps pipelines accumulate years of tribal knowledge, custom tasks, and implicit dependencies.

When migrating pipelines across teams, ranging from simple build-and-deploy flows to complex multi-stage pipelines with environment approvals, artifact promotion, and infrastructure provisioning. This post is the guide let's build for our teams during that process. It covers the YAML translation patterns, the gotchas that cost us days of debugging, and a migration checklist you can use for your own projects.

If you're planning this migration or in the middle of it, this should save you some of the pain we went through.

Understanding the Conceptual Mapping

Before translating YAML, you need to understand how concepts map between the two platforms. Here's the mental model that clicked for our teams:

Azure DevOps              →  GitHub Actions
──────────────────────────────────────────────
Pipeline                  →  Workflow
Stage                     →  Job (with needs: for ordering)
Job                       →  Job
Step/Task                 →  Step/Action
Variable Group            →  Environment secrets / Variables
Service Connection        →  Secrets + OIDC / Federated credentials
Agent Pool                →  Runner (hosted or self-hosted)
Artifact (Pipeline)       →  Artifact (actions/upload-artifact)
Environment + Approvals   →  Environment + Protection rules
Library / Secure Files    →  Secrets
Template (extends/jobs)   →  Reusable workflow / Composite action
Trigger (CI)              →  on: push / pull_request

The biggest conceptual shift: Azure DevOps has a strict hierarchy of Pipeline → Stage → Job → Step. GitHub Actions flattens this to Workflow → Job → Step. Stages in Azure DevOps become separate jobs with needs: dependencies in GitHub Actions.

Translating Pipeline YAML

Let's start with a real-world example. Here's a typical Azure DevOps pipeline for a .NET application:

# Azure DevOps — azure-pipelines.yml
trigger:
  branches:
    include:
      - main
      - release/*

pr:
  branches:
    include:
      - main

variables:
  - group: 'Production-Variables'
  - name: buildConfiguration
    value: 'Release'
  - name: dotnetVersion
    value: '9.0.x'

stages:
  - stage: Build
    displayName: 'Build & Test'
    jobs:
      - job: BuildJob
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: UseDotNet@2
            inputs:
              version: $(dotnetVersion)

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

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

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

          - task: PublishCodeCoverageResults@2
            inputs:
              summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'

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

          - task: PublishBuildArtifacts@1
            inputs:
              PathtoPublish: '$(Build.ArtifactStagingDirectory)'
              ArtifactName: 'webapp'

  - stage: DeployStaging
    displayName: 'Deploy to Staging'
    dependsOn: Build
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: DeployStaging
        environment: 'Staging'
        pool:
          vmImage: 'ubuntu-latest'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: 'Production-ServiceConnection'
                    appName: 'myapp-staging'
                    package: '$(Pipeline.Workspace)/webapp/**/*.zip'

Here's the equivalent GitHub Actions workflow:

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

on:
  push:
    branches: [main, 'release/*']
  pull_request:
    branches: [main]

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

jobs:
  build:
    name: Build & Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Restore
        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"
          --results-directory ./coverage

      - name: Code Coverage Report
        uses: irongut/CodeCoverageSummary@v1.3.0
        if: github.event_name == 'pull_request'
        with:
          filename: coverage/**/coverage.cobertura.xml
          format: markdown
          output: both

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

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: webapp
          path: ./publish
          retention-days: 5

  deploy-staging:
    name: Deploy to Staging
    needs: build
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: staging  # Requires environment protection rules in repo settings

    permissions:
      id-token: write   # Required for OIDC
      contents: read

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

      - name: Login to Azure
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy to Azure Web App
        uses: azure/webapps-deploy@v3
        with:
          app-name: myapp-staging
          package: ./publish

Key differences to note:

  1. Checkout is explicit. Azure DevOps implicitly checks out your code. GitHub Actions requires actions/checkout@v4.
  2. No restore shorthand. Azure DevOps tasks abstract common patterns. GitHub Actions uses direct CLI commands, which gives you more control.
  3. OIDC replaces Service Connections. Instead of a named service connection, you use federated credentials with id-token: write permission.
  4. Stages become jobs with needs:. The deploy-staging job depends on build via the needs keyword.

Secrets and Variable Management

This is where most migrations get messy. Azure DevOps has Variable Groups and Library; GitHub has Secrets and Variables at the repository, environment, and organization level.

Mapping Variable Groups to GitHub:

# Azure DevOps — referencing a variable group
variables:
  - group: 'API-Configuration'
  # Contains: ApiUrl, ApiKey, DbConnection

# GitHub Actions — equivalent using environment secrets
jobs:
  deploy:
    environment: production
    steps:
      - name: Deploy
        env:
          API_URL: ${{ vars.API_URL }}           # Non-sensitive → Variable
          API_KEY: ${{ secrets.API_KEY }}          # Sensitive → Secret
          DB_CONNECTION: ${{ secrets.DB_CONNECTION }}
        run: |
          echo "Deploying with API at $API_URL"

Here's what consider for organizing secrets during migration:

  1. Repository secrets — for values shared across all environments (e.g., AZURE_TENANT_ID)
  2. Environment secrets — for environment-specific values (e.g., DB_CONNECTION differs between staging and production)
  3. Organization secrets — for values shared across multiple repositories (e.g., container registry credentials)

For sensitive files (certificates, config files) that were in Azure DevOps Secure Files:

# Store file contents as a base64-encoded secret
# Set it up: cat cert.pfx | base64 | gh secret set CERT_PFX

steps:
  - name: Restore certificate
    run: |
      echo "${{ secrets.CERT_PFX }}" | base64 --decode > cert.pfx

Handling Artifacts and Caching

Artifact handling is conceptually similar but syntactically different:

# Upload artifacts — equivalent to PublishBuildArtifacts
- uses: actions/upload-artifact@v4
  with:
    name: my-artifact
    path: |
      ./publish/
      !./publish/**/*.pdb
    retention-days: 5
    compression-level: 6

# Download in another job — equivalent to DownloadBuildArtifacts
- uses: actions/download-artifact@v4
  with:
    name: my-artifact
    path: ./downloaded

For NuGet and npm caching (replaces Azure DevOps Cache task):

# .NET project caching
- uses: actions/setup-dotnet@v4
  with:
    dotnet-version: '9.0.x'
    cache: true                    # Built-in NuGet cache support
    cache-dependency-path: |
      **/*.csproj

# Or manual caching for more control
- uses: actions/cache@v4
  with:
    path: ~/.nuget/packages
    key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
    restore-keys: |
      ${{ runner.os }}-nuget-

Self-Hosted Runners

If you're using self-hosted agents in Azure DevOps, the migration to self-hosted runners is straightforward but requires infrastructure work:

# Azure DevOps — self-hosted agent pool
pool:
  name: 'MyAgentPool'
  demands:
    - docker
    - Agent.OS -equals Linux

# GitHub Actions — self-hosted runner with labels
jobs:
  build:
    runs-on: [self-hosted, linux, docker]

For our migration, we set up runners using the GitHub Actions Runner Controller (ARC) on our existing Kubernetes cluster:

# actions-runner-controller Helm values
controllerManager:
  replicaCount: 1

githubConfigUrl: "https://github.com/myorg"
githubConfigSecret:
  github_app_id: "12345"
  github_app_installation_id: "67890"
  github_app_private_key: |
    -----BEGIN RSA PRIVATE KEY-----
    ...

template:
  spec:
    containers:
      - name: runner
        image: ghcr.io/actions/actions-runner:latest
        resources:
          requests:
            cpu: "2"
            memory: "4Gi"
          limits:
            cpu: "4"
            memory: "8Gi"

One thing we didn't anticipate: Azure DevOps agents maintain state between runs by default (unless you use scale set agents). GitHub Actions self-hosted runners also persist state, which can cause flaky builds. Use ephemeral runners or add cleanup steps.

Reusable Workflows: Replacing Templates

Azure DevOps templates are powerful, and the closest equivalent in GitHub Actions is reusable workflows combined with composite actions:

# .github/workflows/dotnet-build-template.yml — reusable workflow
name: .NET Build Template

on:
  workflow_call:
    inputs:
      dotnet-version:
        required: false
        type: string
        default: '9.0.x'
      configuration:
        required: false
        type: string
        default: 'Release'
      project-path:
        required: true
        type: string
    secrets:
      NUGET_AUTH_TOKEN:
        required: false

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

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

      - name: Restore
        run: dotnet restore ${{ inputs.project-path }}
        env:
          NUGET_AUTH_TOKEN: ${{ secrets.NUGET_AUTH_TOKEN }}

      - name: Build
        run: >
          dotnet build ${{ inputs.project-path }}
          --configuration ${{ inputs.configuration }}
          --no-restore

      - name: Test
        run: >
          dotnet test ${{ inputs.project-path }}
          --configuration ${{ inputs.configuration }}
          --no-build

      - name: Publish
        run: >
          dotnet publish ${{ inputs.project-path }}
          --configuration ${{ inputs.configuration }}
          --output ./publish

      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: ./publish

Consuming the reusable workflow:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]

jobs:
  build:
    uses: ./.github/workflows/dotnet-build-template.yml
    with:
      project-path: './src/MyApp/MyApp.csproj'
      configuration: 'Release'
    secrets:
      NUGET_AUTH_TOKEN: ${{ secrets.NUGET_AUTH_TOKEN }}

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build-output
      - run: echo "Deploy steps here"

Migration Checklist

Here's the checklist we used for each pipeline migration. Print this out and check off items as we went:

## Pre-Migration
- [ ] Document all variable groups and their values
- [ ] List all service connections and their target resources
- [ ] Inventory all pipeline tasks and find GitHub Action equivalents
- [ ] Identify any custom tasks that need replacement
- [ ] Map environments and approval gates
- [ ] Review agent pool requirements (OS, software, capabilities)

## Repository Setup
- [ ] Create GitHub repository (or mirror from Azure DevOps)
- [ ] Configure branch protection rules (replaces branch policies)
- [ ] Set up environments with protection rules
- [ ] Configure repository secrets and variables
- [ ] Set up organization-level secrets if applicable
- [ ] Configure OIDC federation for Azure deployments

## Pipeline Translation
- [ ] Create .github/workflows/ directory
- [ ] Translate trigger configuration
- [ ] Translate build steps
- [ ] Translate test steps with coverage reporting
- [ ] Translate artifact publish/download
- [ ] Translate deployment steps
- [ ] Add caching configuration
- [ ] Translate notification steps (Slack, Teams, email)

## Validation
- [ ] Run workflow on a feature branch
- [ ] Verify all tests pass
- [ ] Verify artifacts are published correctly
- [ ] Verify deployment to staging environment
- [ ] Verify environment approvals work
- [ ] Verify secrets are accessible
- [ ] Compare build times with Azure DevOps baseline
- [ ] Run 5+ builds to check for flakiness

## Cutover
- [ ] Disable Azure DevOps pipeline (don't delete yet)
- [ ] Enable GitHub Actions workflow on main branch
- [ ] Monitor first production deployment
- [ ] Keep Azure DevOps pipeline for 30 days as fallback
- [ ] Update team documentation and runbooks
- [ ] Archive Azure DevOps pipeline after 30 days

Key Takeaways

  1. Migrate incrementally. Start with your simplest pipeline, learn the patterns, then tackle complex ones. Don't try to migrate everything in a sprint.
  2. OIDC is the way. Don't store Azure credentials as secrets. Set up federated identity credentials — it's more secure and eliminates credential rotation headaches.
  3. Invest in reusable workflows early. The time you spend building shared templates pays for itself by the third pipeline migration.
  4. Test on a branch first. GitHub Actions runs on any branch with a workflow file. Create a migration branch, get the workflow green, then merge.
  5. Keep Azure DevOps pipelines as fallback. Disable them but don't delete for at least 30 days. You'll want the reference material, and you might need to roll back.

The migration is an investment, but the payoff is real — GitHub Actions' marketplace ecosystem, the tight integration with pull requests, and the simpler YAML syntax make day-to-day pipeline maintenance significantly easier. Our teams spend less time fighting CI/CD and more time shipping features.

Share this post

Comments

Ajit Gangurde

Software Engineer II at Microsoft | 15+ years in .NET & Azure