# Migrate Azure Pipelines to GitHub Actions, concept by concept

> Migrate Azure Pipelines to GitHub Actions: stages, pools, variable groups, templates, service connections and approvals, with a before and after.

Source: https://latchkey.dev/learn/migrate/azure-pipelines-to-github-actions  
Updated: 2026-11-12

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.

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](/learn/runners/github-actions-vs-azure-pipelines) and is not repeated here. The `${{ }}`, `$[ ]` and `$( )` syntaxes get their own page, [Azure Pipelines template expressions](/learn/migrate/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 its `demands`. Microsoft-hosted `vmImage: ubuntu-latest` becomes `runs-on: ubuntu-latest`; each self-hosted demand becomes a label on the runner and in `runs-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 after `azure/login`.
- **Cache keys.** `Cache@2` keys such as `npm | "$(Agent.OS)" | package-lock.json` become `hashFiles()` keys in `actions/cache`, or the `cache:` input of a setup action. GitHub keeps 10 GB per repository by default and removes entries not accessed in 7 days.
- **Matrix.** `strategy: matrix` with named legs becomes `strategy.matrix` with axes. GitHub defaults `fail-fast` to `true` and caps a matrix at 256 jobs per run.
- **Artifacts.** `publish` and `download: current` become `actions/upload-artifact` and `actions/download-artifact`, retained up to 90 days unless the repository setting says otherwise.
- **Concurrency.** Two pushes to `main` can deploy at the same time on GitHub unless the deploy job names a `concurrency:` group, so add one wherever an Azure environment has an exclusive lock. Note its `lockBehavior`: `sequential` needs `queue: max` on the GitHub side, while the default `runLatest` is 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-days` input in `actions/upload-artifact` v7.0.2's `action.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.

```azure-pipelines.yml
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.

```.github/workflows/ci.yml
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](/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)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
