To migrate Azure Pipelines to GitHub Actions, map concepts rather than lines: stages become jobs chained with needs, pools and demands become runs-on labels, variable groups become secrets and configuration variables, templates become reusable workflows and composite actions, and service connections become OIDC federation. The parts with no one-to-one equivalent are compile-time template logic, ##vso logging commands and environment approvals on private repositories, so plan those first.

Each Azure Pipelines concept and the GitHub Actions construct that replaces it. The right-hand column is what changes in behavior, not just in spelling.
You have an azure-pipelines.yml that works, a repository that has moved or is moving to GitHub, and a deadline. Most of the file translates mechanically. The time goes on the handful of Azure features that have a different shape on the GitHub side, and on one plan limit that decides whether your approval gates survive the move at all.
This page is the translation. The billing comparison, lanes against minutes, is on GitHub Actions vs Azure Pipelines and is not repeated here. The ${{ }}, $[ ] and $( ) syntaxes get their own page, Azure Pipelines template expressions, because that is where most hand migrations break.
The concept map
Nine concepts cover almost every azure-pipelines.yml. The right-hand column lists the differences that compile fine and then surprise you on the first run.
| Azure Pipelines | GitHub Actions | What changes |
|---|---|---|
stages |
Jobs chained with needs
|
There is no stage keyword. A stage's jobs become jobs, and dependsOn becomes needs
|
job, deployment job |
jobs.<id>, with environment: for a deployment |
Azure checks out self in a normal job; a GitHub job needs actions/checkout as a step |
script, bash, pwsh, task
|
run: and uses:
|
One run: key; shell: picks the shell. A task's inputs: become an action's with:
|
pool: vmImage, demands
|
runs-on: label or labels |
Self-hosted demands become runner labels |
variables, variable groups |
env:, vars, secrets
|
No group object. Scope values to the repository, the organization or an environment |
| Step, job and stage templates | Composite actions, reusable workflows | No compile-time ${{ if }} or ${{ each }} insertion |
| Service connections | OIDC: azure/login with id-token: write
|
No stored key. Trust is a federated credential on the Azure side |
| Environments, approvals and checks |
environment: with required reviewers |
Required reviewers on private repositories need GitHub Enterprise |
condition: eq(...) |
if: with ==
|
succeeded() becomes success(); functions become operators |
Rows 1 to 3 and 9 follow GitHub's own Azure Pipelines migration guide; the checkout rule is from Microsoft's deployment jobs page, and the plan limit from GitHub's deployments and environments reference, all read 2026-11-12.
The migration checklist
Work through these in the Azure project before you write the new file. Each one is something the YAML alone does not tell you.
-
Labels. List every
pool:and itsdemands. Microsoft-hostedvmImage: ubuntu-latestbecomesruns-on: ubuntu-latest; each self-hosted demand becomes a label on the runner and inruns-on. -
Secrets. Export the names, not the values, from every variable group and from the pipeline's Variables tab. Secret ones go to GitHub secrets, plain ones to configuration variables (
vars). A group linked to Key Vault becomes a step that reads Key Vault afterazure/login. -
Cache keys.
Cache@2keys such asnpm | "$(Agent.OS)" | package-lock.jsonbecomehashFiles()keys inactions/cache, or thecache:input of a setup action. GitHub keeps 10 GB per repository by default and removes entries not accessed in 7 days. -
Matrix.
strategy: matrixwith named legs becomesstrategy.matrixwith axes. GitHub defaultsfail-fasttotrueand caps a matrix at 256 jobs per run. -
Artifacts.
publishanddownload: currentbecomeactions/upload-artifactandactions/download-artifact, retained up to 90 days unless the repository setting says otherwise. -
Concurrency. Two pushes to
maincan deploy at the same time on GitHub unless the deploy job names aconcurrency:group, so add one wherever an Azure environment has an exclusive lock. Note itslockBehavior:sequentialneedsqueue: maxon the GitHub side, while the defaultrunLatestis close to GitHub's default. - Approvals. Note every environment with an approval check. On a Free, Pro or Team plan, GitHub offers required reviewers only on public repositories.
Cache limits are from GitHub's dependency caching reference, the 256-job matrix cap from GitHub Actions limits, and the 90-day artifact ceiling from the
retention-daysinput inactions/upload-artifactv7.0.2'saction.yml, all read 2026-11-12.
Before: a two-stage azure-pipelines.yml
A typical shape: a Build stage with a Node matrix and a build job that publishes dist, then a Deploy stage that runs only on main, waits on the production environment's approval and deploys through a service connection.
Two details matter for the translation. The deployment job has its own checkout: self, because Microsoft documents that a deployment job does not clone the repository, and the variable group web-shared is invisible in the file: its contents live in the Library.
trigger:
branches:
include: [main]
pr:
branches:
include: [main]
variables:
- group: web-shared
stages:
- stage: Build
jobs:
- job: test
pool:
vmImage: ubuntu-latest
strategy:
matrix:
node22:
nodeVersion: '22.x'
node24:
nodeVersion: '24.x'
steps:
- task: NodeTool@0
inputs:
versionSpec: $(nodeVersion)
- task: Cache@2
inputs:
key: 'npm | "$(Agent.OS)" | package-lock.json'
path: $(Pipeline.Workspace)/.npm
- script: npm ci --cache $(Pipeline.Workspace)/.npm && npm test
- job: build
pool:
vmImage: ubuntu-latest
steps:
- script: npm ci && npm run build
- publish: dist
artifact: web
- stage: Deploy
dependsOn: Build
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: deploy
environment: production
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- checkout: self
- download: current
artifact: web
- task: AzureCLI@2
inputs:
azureSubscription: prod-service-connection
scriptType: bash
scriptLocation: inlineScript
inlineScript: ./scripts/deploy.sh
After: the same pipeline as one workflow
The two stages collapse into three jobs. deploy declares needs: [test, build], which is what the stage boundary did, and its if: is the stage condition with the function call turned into an operator. The named matrix legs become one node axis, and NodeTool@0 plus Cache@2 become actions/setup-node with its cache: npm input.
The service connection is gone. azure/login exchanges the job's OIDC token for an Azure session, which needs id-token: write on that job and a federated credential on the Azure app registration whose subject names this repository and the production environment. The three IDs are not secrets, so they live in vars. Pin every action to a full commit SHA, as here.
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['22', '24']
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci && npm test
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '24'
cache: npm
- run: npm ci && npm run build
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: web
path: dist
deploy:
needs: [test, build]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
concurrency: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: web
path: web
- uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- run: ./scripts/deploy.sh
Templates and logging commands: the parts that do not map
Azure templates run at compile time. ${{ if }} inserts or removes a step before the run exists, and ${{ each }} stamps out one job per list item. GitHub Actions has no insertion phase. A step template becomes a composite action, a job or stage template becomes a reusable workflow called with uses: at the job level, a conditional insertion becomes an if: on the job or step, and an each loop becomes a matrix, built with fromJSON when the list is dynamic. Reusable workflows nest up to ten levels, and one file can call at most 50 unique ones.
Logging commands are the other rewrite. ##vso[task.setvariable variable=x] becomes a line appended to $GITHUB_ENV, and the isOutput=true form becomes a line appended to $GITHUB_OUTPUT read back as needs.<job>.outputs.<name> once the job declares it under outputs:. Grep for ##vso[ before you start; every hit is a manual edit.
The ten-level nesting limit and the 50-workflow cap are from GitHub Docs, reusing workflow configurations, read 2026-11-12.
Environments and approvals
An Azure environment with an Approvals check becomes a GitHub environment with required reviewers, up to six users or teams, plus an optional wait timer of up to 43,200 minutes. The job opts in with environment: production, and the run pauses before that job starts.
The catch is the plan. GitHub's reference states that on Free, Pro and Team plans, required reviewers and wait timers are only available for public repositories. A private repository on GitHub Team keeps its environment and its secrets but loses the gate. If your Azure approvals are a compliance control, settle this before the cutover, not after.
An exclusive lock check becomes the concurrency: block. Azure's default lockBehavior: runLatest lets only the latest run take the lock, which is close to GitHub's default: one run deploys, one waits, and a newer run cancels the one waiting. For lockBehavior: sequential, where every run deploys in turn, add queue: max, which holds up to 100 waiting runs.
```.github/workflows/ci.yml (deploy job)
deploy:
needs: [test, build]
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
concurrency:
group: production
queue: max # was: exclusive lock with lockBehavior: sequential
> Exclusive lock and `lockBehavior` from Microsoft Learn, pipeline deployment approvals. Pending runs and `queue: max` from [GitHub Docs: Control the concurrency of workflows and jobs](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency); `queue: max` cannot be combined with `cancel-in-progress: true`. Both read 2026-11-12.
## Let GitHub Actions Importer draft it, then read every line
GitHub Actions Importer has an `azure-devops` subcommand; what it converts and what it leaves is on [GitHub Actions Importer](https://latchkey.dev/learn/migrate/github-actions-importer). Use it for the mechanical rows of the table above, then do the templates, the logging commands and the approvals by hand.
Run the old and new pipelines side by side for a week on the same commits. A matching green is weak evidence; a matching red on a commit you broke on purpose is strong evidence that the conditions and needs edges came across.
## FAQ
### How do I convert Azure Pipelines stages to GitHub Actions?
Make each stage's jobs ordinary jobs and replace `dependsOn` between stages with `needs` between jobs. The stage `condition` moves to the `if:` of the first job that used to be in that stage.
### What is the GitHub Actions equivalent of an Azure DevOps variable group?
There is no group object. Secret values become repository, organization or environment secrets, and plain values become configuration variables read through the `vars` context. Environment-scoped secrets are the closest match to a group linked to one stage.
### What is the GitHub Actions equivalent of task.setvariable?
For a later step in the same job, append `name=value` to the file in `$GITHUB_ENV` and read it as an environment variable. For another job, append it to `$GITHUB_OUTPUT` in a step with an `id`, declare it under the job's `outputs:`, and read it as `needs.<job>.outputs.<name>`.
### How do I replace an Azure service connection in GitHub Actions?
Use OpenID Connect. Add a federated credential to the Azure app registration that trusts your repository, grant the job `id-token: write`, and sign in with `azure/login` using `client-id`, `tenant-id` and `subscription-id`. No client secret is stored in GitHub.
## References
- [GitHub Docs: Migrating from Azure Pipelines to GitHub Actions (verified 2026-11-12)](https://docs.github.com/en/actions/tutorials/migrate-to-github-actions/manual-migrations/migrate-from-azure-pipelines)
- [GitHub Docs: deployments and environments, reviewers and plan limits (verified 2026-11-12)](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments)
- [Microsoft Learn: deployment jobs in Azure Pipelines (verified 2026-11-12)](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/deployment-jobs)
- [GitHub Docs: configuring OpenID Connect in Azure (verified 2026-11-12)](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-azure)
- [GitHub Docs: automating migration with GitHub Actions Importer (verified 2026-11-12)](https://docs.github.com/en/actions/tutorials/migrate-to-github-actions/automated-migrations/use-github-actions-importer)
Top comments (0)