Skip to main content

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

February 14, 2026 5 min read

Migrating CI/CD from Azure DevOps to GitHub Actions is where teams spend most of their effort — not because Actions is harder, but because Azure DevOps pipelines accumulate years of tribal knowledge. Here's the practical translation guide with real YAML examples and a migration checklist to keep things on track.

Conceptual Mapping

Azure DevOps              →  GitHub Actions
──────────────────────────────────────────────
Pipeline                  →  Workflow
Stage                     →  Job (with needs: for ordering)
Step/Task                 →  Step/Action
Variable Group            →  Environment secrets / Variables
Service Connection        →  OIDC / Federated credentials
Agent Pool                →  Runner (hosted or self-hosted)
Environment + Approvals   →  Environment + Protection rules
Template                  →  Reusable workflow / Composite action

The biggest shift: Azure DevOps's Pipeline → Stage → Job → Step becomes Workflow → Job → Step. Stages become separate jobs with needs: dependencies. Once this mental model clicks, the rest falls into place.

YAML Translation Example

Here's a .NET build-and-deploy pipeline translated to GitHub Actions:

name: Build and Deploy
on:
  push:
    branches: [main, 'release/*']
  pull_request:
    branches: [main]

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

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}
      - run: dotnet restore
      - run: dotnet build --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore
      - run: dotnet test --configuration ${{ env.BUILD_CONFIGURATION }} --no-build
      - run: dotnet publish --configuration ${{ env.BUILD_CONFIGURATION }} --output ./publish
      - uses: actions/upload-artifact@v4
        with:
          name: webapp
          path: ./publish

  deploy-staging:
    needs: build
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: staging
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: webapp
          path: ./publish
      - 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: myapp-staging
          package: ./publish

Key differences: checkout is explicit (actions/checkout@v4), OIDC replaces service connections, and stages become jobs with needs:. Also note the permissions block — GitHub Actions uses a least-privilege model where you explicitly declare what the workflow needs access to.

Reusable Workflows: The Template Equivalent

In Azure DevOps, you'd use pipeline templates to share logic. In GitHub Actions, reusable workflows serve the same purpose. Define a callable workflow once and invoke it from multiple pipelines:

# .github/workflows/dotnet-build-reusable.yml
name: .NET Build (Reusable)
on:
  workflow_call:
    inputs:
      dotnet-version:
        required: false
        type: string
        default: '9.0.x'
      project-path:
        required: true
        type: string
    outputs:
      artifact-name:
        value: ${{ jobs.build.outputs.artifact-name }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      artifact-name: ${{ steps.set-name.outputs.name }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ inputs.dotnet-version }}
      - run: dotnet restore ${{ inputs.project-path }}
      - run: dotnet build ${{ inputs.project-path }} -c Release --no-restore
      - run: dotnet test ${{ inputs.project-path }} -c Release --no-build
      - id: set-name
        run: echo "name=build-$(date +%s)" >> $GITHUB_OUTPUT

Calling workflows consume this with a simple uses reference, passing only the inputs that differ between projects. This eliminates duplication across repositories and ensures consistency in build processes.

Secrets and Variables

Map Variable Groups to GitHub's three-tier secret model:

  • Repository secrets — shared across environments (e.g., AZURE_TENANT_ID)
  • Environment secrets — environment-specific (e.g., DB_CONNECTION)
  • Organization secrets — shared across repos (e.g., registry credentials)

For Secure Files, base64-encode the file and store as a secret:

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

Setting Up OIDC for Azure

OIDC (OpenID Connect) federated credentials replace service connections. This eliminates stored secrets entirely — the workflow requests a short-lived token from Azure AD using the GitHub OIDC provider:

# Create federated credential for your GitHub repo
az ad app federated-credential create \
  --id <APP_OBJECT_ID> \
  --parameters '{
    "name": "github-main-branch",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:your-org/your-repo:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'

Then in your workflow, the azure/login@v2 action handles the token exchange automatically. No more rotating service principal secrets every 90 days.

Migration Checklist

Pre-Migration: Document variable groups, list service connections, inventory pipeline tasks, map environments and approval gates. Export your existing YAML pipelines for reference.

Repository Setup: Configure branch protection rules, set up environments with protection rules, configure OIDC federation for Azure deployments.

Validation: Run workflow on a feature branch, verify tests/artifacts/deployments, compare build times, run 5+ builds to check for flakiness.

Cutover: Disable (don't delete) Azure DevOps pipelines, monitor first production deployment, keep as fallback for 30 days.

Common Gotchas

  • Matrix builds replace Azure DevOps strategy patterns — use strategy.matrix for multi-platform or multi-version builds
  • Caching needs explicit setup with actions/cache@v4 — Azure DevOps caches NuGet/npm automatically
  • Artifact retention defaults to 90 days in GitHub Actions vs. configurable in Azure DevOps — set retention-days explicitly
  • Self-hosted runners require different setup than Azure DevOps agents — use runner groups for organization-level management

Key Takeaways

  1. Migrate incrementally — start with the simplest pipeline, learn patterns, then tackle complex ones.
  2. Use OIDC — federated credentials are more secure and eliminate credential rotation.
  3. Invest in reusable workflows early — pays for itself by the third pipeline.
  4. Test on a branch first — get the workflow green before merging.
  5. Keep Azure DevOps as fallback — disable but don't delete for 30 days.

References

  1. Migrating from Azure Pipelines to GitHub Actions — Official GitHub migration guide with syntax mapping
  2. GitHub Actions documentation — Comprehensive guide to workflows, actions, and runners
  3. Configuring OpenID Connect in Azure — Setting up OIDC federated credentials for Azure deployments
  4. Reusable workflows — How to create and call reusable workflows across repositories
  5. Azure Login GitHub Action — Official Azure login action for GitHub Actions with OIDC support
Share this post

Comments

Ajit Gangurde

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