DEV Community

Cover image for Migrate Azure Pipelines to GitHub Actions, concept by concept
Kay
Kay

Posted on Originally published at latchkey.dev

Migrate Azure Pipelines to GitHub Actions, concept by concept

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.

Nine Azure Pipelines concepts, from stages to conditions, each beside its GitHub Actions equivalent
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 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.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)