Migrating from Azure DevOps to GitHub Actions: A Step-by-Step Guide
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:
- Checkout is explicit. Azure DevOps implicitly checks out your code. GitHub Actions requires
actions/checkout@v4. - No restore shorthand. Azure DevOps tasks abstract common patterns. GitHub Actions uses direct CLI commands, which gives you more control.
- OIDC replaces Service Connections. Instead of a named service connection, you use federated credentials with
id-token: writepermission. - Stages become jobs with
needs:. Thedeploy-stagingjob depends onbuildvia theneedskeyword.
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:
- Repository secrets — for values shared across all environments (e.g.,
AZURE_TENANT_ID) - Environment secrets — for environment-specific values (e.g.,
DB_CONNECTIONdiffers between staging and production) - 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
- Migrate incrementally. Start with your simplest pipeline, learn the patterns, then tackle complex ones. Don't try to migrate everything in a sprint.
- 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.
- Invest in reusable workflows early. The time you spend building shared templates pays for itself by the third pipeline migration.
- 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.
- 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.
Comments
Ajit Gangurde
Software Engineer II at Microsoft | 15+ years in .NET & Azure