Skip to content
damionas
No. 49DevOpsFeb 23, 202626 min read

Migrating Classic Release Pipelines to YAML, the Six-Week Phased Plan

The Azure DevOps organisation I was asked to modernise had eighty-three Classic Release pipelines, the oldest dating to 2017.

The Azure DevOps organisation I was asked to modernise had eighty-three Classic Release pipelines, the oldest dating to 2017. Two of them deployed production every Tuesday and had not been touched in three years; the team that owned them had rotated twice. Microsoft had announced the deprecation of Classic Release a few quarters earlier, and the security team had a hard-stop date six months out. We needed every pipeline migrated to YAML before then, with zero deployment-day surprises.

The naive approach (rewrite all eighty-three in a sprint) was not going to work. The pipelines had subtle behaviours nobody remembered: a pre-deployment gate that called a long-defunct webhook, a ServiceNow approval check on Stage 3, a deployment group with three on-prem servers that had to be drained in a specific order. Re-implementing those by reading the Classic UI was a recipe for outages.

The phased plan is the approach that worked. Pick one pipeline. Capture its current behaviour in a YAML file alongside the Classic. Run both in parallel for two weeks. Compare outputs. Cut over. Repeat. Six weeks of throughput got us from eighty-three to zero, with one production incident (a recovered-in-six-minutes ServiceNow auth issue) along the way.

This post is the entire playbook: how to inventory Classic pipelines, how to capture their behaviour mechanically, the three categories of pipeline (easy, medium, hard) and how to schedule them, the parallel-run pattern that eliminates cutover risk, and the team rituals that keep the migration moving. About 200 lines of script and a calendar.

Why phased, and not big-bang

Brief context because the temptation to rewrite all at once is real and you should resist it.

Classic pipelines accumulate undocumented behaviour. Variable groups inherited from defunct projects, gates pointing at retired systems, deployment groups with hand-curated targets. None of this is in the YAML you'd write from scratch. Big-bang rewrites lose the behaviour and recover it on the day a deploy fails in production.

The conversion tool is a starting point, not a finished product. Azure DevOps has an export-to-YAML feature for Classic pipelines that emits a rough draft. It handles maybe 60% of pipelines correctly; the other 40% need manual reconciliation. Treating the export as the final answer is the second-most-common mistake.

Parallel runs are the only safe cutover pattern. Running the new YAML pipeline against staging while the old Classic pipeline keeps deploying production for two weeks gives you side-by-side proof. If the YAML pipeline succeeds where Classic succeeds and fails where Classic fails, the cutover is boring. If the parallel runs disagree, you investigate before any prod traffic moves.

A migration calendar is the operational deliverable. Without a calendar, the migration goes "we'll get to it" forever. With a calendar (one pipeline migrated per week, seven slots per sprint, named owners per pipeline), the migration finishes on a known date.

What you'll have at the end

~/classic-yaml-migration/
├── inventory/
│   └── pipelines.csv                      # the full list with categorisation
├── tools/
│   ├── export-classic.sh                  # bulk-export Classic to YAML drafts
│   ├── compare-runs.sh                    # diff a Classic run vs a YAML run
│   └── inventory-snapshot.sh              # daily pipeline status report
├── pipelines/
│   ├── _template-deploy.yml               # canonical YAML template
│   └── <one-yaml-per-migrated-pipeline>
└── docs/
    ├── migration-calendar.md
    └── runbook-cutover.md

Prerequisites

  • Azure DevOps organisation with Classic pipelines you can read. If you have admin on the org, you have what you need.
  • Personal Access Token with Build (read) and Release (read & manage) scopes, or a managed identity equivalent.
  • A test branch or sandbox project to run the YAML drafts in before they touch production.
  • Microsoft's official guidance: Migrate from Classic Release to YAML pipelines, the canonical reference for the converter tool and what it covers.

Step 1: Inventory every Classic pipeline

You can't migrate what you can't list. The first step is a CSV of every Classic pipeline in the org, with enough metadata to categorise them.

tools/inventory-snapshot.sh:

#!/usr/bin/env bash
# Snapshot every Classic Release pipeline in the org as a CSV.
# Each row: id, name, project, last-run, deploy-targets, gates, var-groups.

set -euo pipefail

ORG="${ADO_ORG:?missing}"
PAT="${ADO_PAT:?missing}"

echo "id,name,project,lastRun,deployTargetsCount,gatesCount,varGroupsCount,classification"

# Iterate every project
PROJECTS=$(curl -sS -u ":$PAT" \
  "https://dev.azure.com/$ORG/_apis/projects?api-version=7.1" \
  | jq -r '.value[].name')

for PROJECT in $PROJECTS; do
  # List all Release definitions in the project
  DEFS=$(curl -sS -u ":$PAT" \
    "https://vsrm.dev.azure.com/$ORG/$PROJECT/_apis/release/definitions?api-version=7.1&\$expand=environments")

  echo "$DEFS" | jq -r --arg project "$PROJECT" '
    .value[] |
    [
      .id,
      .name,
      $project,
      (.modifiedOn // ""),
      ([.environments[].deployPhases[].deploymentInput.deploymentGroupId | select(. != null)] | length),
      ([.environments[].preDeployApprovals.approvals[]?.approver, .environments[].postDeployApprovals.approvals[]?.approver] | map(select(. != null)) | length),
      ([.variableGroups[]?] | length),
      "TBD"
    ] | @csv
  '
done

Run it and pipe to a CSV file:

bash tools/inventory-snapshot.sh > inventory/pipelines.csv
wc -l inventory/pipelines.csv

Eighty-three lines in our case. The classification column is "TBD" because step 2 is to fill it in.

Step 2: Categorise each pipeline

Three categories, in increasing order of migration cost:

Easy (single-stage, no gates, no deployment groups, ≤2 variable groups, recent activity):

  • Pipeline runs are mostly az CLI scripts.
  • Variables are static, sourced from one or two groups.
  • No ServiceNow, no Slack-approval, no custom gate.
  • Last run within the last 30 days.

Medium (multi-stage, one or two gates, manual approvals, deployment to App Service or Functions):

  • Pipeline crosses environments (dev → staging → prod).
  • Has manual approvals at one or more gates.
  • Deploys to managed Azure compute (App Service slots, Function Apps, AKS).
  • Variable substitution uses task groups or simple transforms.

Hard (deployment groups, custom gates, complex variable transforms, on-prem targets):

  • Targets on-prem servers via deployment group.
  • Has a custom gate (REST webhook, Azure Function, ServiceNow).
  • Variable transforms include complex JSON substitution or task-group inheritance chains.
  • Last meaningful update was more than a year ago.

In our org, the split was roughly 30/40/13. The hard ones are 16% of the pipelines and 60% of the time. Plan accordingly.

Mark each row in inventory/pipelines.csv with its category, then sort:

# After hand-classifying, sort easy first
sort -t, -k8 inventory/pipelines.csv > inventory/pipelines-sorted.csv

Step 3: Bulk-export the Classic pipelines as YAML drafts

The Azure DevOps UI has a "Export YAML" button on each Classic pipeline; clicking it 83 times is exactly the kind of work you don't want to do. Bulk-export via the REST API:

tools/export-classic.sh:

#!/usr/bin/env bash
# Export every Classic Release definition as YAML using the export endpoint.
# Output: pipelines/draft/<projectname>/<pipelinename>.yml

set -euo pipefail

ORG="${ADO_ORG:?}"
PAT="${ADO_PAT:?}"

mkdir -p pipelines/draft

while IFS=, read -r ID NAME PROJECT _; do
  [ "$ID" = "id" ] && continue
  PROJECT_DIR="pipelines/draft/$(echo "$PROJECT" | tr -d '"')"
  mkdir -p "$PROJECT_DIR"

  OUT="$PROJECT_DIR/$(echo "$NAME" | tr -d '"' | tr '/' '-' | tr ' ' '-').yml"

  echo "Exporting $NAME -> $OUT"
  curl -sS -u ":$PAT" \
    "https://vsrm.dev.azure.com/$ORG/$PROJECT/_apis/release/definitions/$ID/yamlexport?api-version=7.1-preview.1" \
    -o "$OUT"

  # The endpoint returns an empty file when conversion fails.
  if [ ! -s "$OUT" ]; then
    echo "  (failed to export; will need manual rewrite)"
  fi
done < inventory/pipelines.csv

echo "Done. Drafts in pipelines/draft/."

Run it. Inspect the output:

bash tools/export-classic.sh
find pipelines/draft -name '*.yml' | wc -l
find pipelines/draft -name '*.yml' -size 0 | wc -l   # how many failed

The empty-file count tells you how many will need full rewrites versus tweaks. In our org, 22 of 83 (27%) exported empty.

Step 4: The canonical YAML template

Most pipelines will fall into one of a handful of shapes. Define a canonical template that the migrated pipelines extend, so you're not re-inventing the build/test/deploy scaffold per pipeline.

pipelines/_template-deploy.yml:

# Canonical multi-stage deploy template.
# Used by all migrated pipelines via 'extends'.
parameters:
  - name: appName
    type: string
  - name: serviceConnection
    type: string
  - name: resourceGroup
    type: string
  - name: environment
    type: string

stages:
  - stage: Build
    jobs:
      - job: Build
        steps:
          - checkout: self
          - bash: |
              echo "Building ${{ parameters.appName }}..."
              # Replace with your actual build step
            displayName: Build

  - stage: Deploy
    dependsOn: Build
    condition: succeeded()
    jobs:
      - deployment: Deploy
        environment: ${{ parameters.environment }}
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureCLI@2
                  inputs:
                    azureSubscription: ${{ parameters.serviceConnection }}
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: |
                      az webapp deployment source config-zip \
                        -g ${{ parameters.resourceGroup }} \
                        -n ${{ parameters.appName }} \
                        --src $(Pipeline.Workspace)/drop/${{ parameters.appName }}.zip

Migrated pipelines reference this template:

# pipelines/payments-prod.yml
trigger:
  branches:
    include: [ main ]
  paths:
    include: [ src/payments/** ]

extends:
  template: _template-deploy.yml
  parameters:
    appName: payments-prod
    serviceConnection: sc-payments-prod
    resourceGroup: rg-payments-prod
    environment: production

A real migrated pipeline ends up at 8 to 15 lines of YAML, because all the build/deploy logic is in the template. Compare to the 200-line Classic JSON definition; the migration is a substantial readability win.

Step 5: The parallel-run pattern

The cutover step that eliminates risk: run the new YAML pipeline against the same code as the Classic pipeline, two weeks of every deploy, before flipping production traffic.

The pattern:

  1. Classic pipeline keeps deploying production. Don't touch it.
  2. New YAML pipeline deploys to a parallel staging-like environment. Resource group is named rg-<app>-yaml-validation. Same Bicep, same code, different name.
  3. Every Classic prod deploy is followed by a YAML deploy to the validation environment within an hour.
  4. Output of both deploys is captured (artefacts, logs, smoke-test results).
  5. Two weeks pass. If the deltas are explained (or zero), cutover.

tools/compare-runs.sh:

#!/usr/bin/env bash
# Compare a Classic Release run against a YAML pipeline run.
# Output: a delta report that goes into the migration log.

set -euo pipefail

ORG="${ADO_ORG:?}"
PAT="${ADO_PAT:?}"
CLASSIC_RELEASE_ID="${1:?usage: compare-runs.sh <classic-release-id> <yaml-build-id>}"
YAML_BUILD_ID="${2:?}"

# Fetch both
CLASSIC=$(curl -sS -u ":$PAT" \
  "https://vsrm.dev.azure.com/$ORG/$PROJECT/_apis/release/releases/$CLASSIC_RELEASE_ID?api-version=7.1")
YAML=$(curl -sS -u ":$PAT" \
  "https://dev.azure.com/$ORG/$PROJECT/_apis/build/builds/$YAML_BUILD_ID?api-version=7.1")

# Compare key fields
echo "=== Outcome ==="
echo "Classic: $(echo "$CLASSIC" | jq -r '.environments[-1].status')"
echo "YAML:    $(echo "$YAML" | jq -r '.result')"

echo "=== Duration ==="
echo "Classic: $(echo "$CLASSIC" | jq -r '.environments[-1].timeToDeploy')"
echo "YAML:    $(echo "$YAML" | jq -r '(.finishTime | fromdateiso8601) - (.startTime | fromdateiso8601)') seconds"

echo "=== Artefacts ==="
echo "Classic: $(echo "$CLASSIC" | jq -r '.artifacts[].definitionReference.version.name')"
echo "YAML:    $(echo "$YAML" | jq -r '.sourceVersion')"

Two weeks of these runs gives you a folder of paired comparisons. In our migration, 71 of 83 pipelines had identical outcomes and similar durations from day one. Twelve had differences worth investigating; six of those were pipeline bugs we'd been carrying for years (Classic was deploying the wrong artefact in one case, which we discovered only because YAML deployed the right one).

Step 6: The cutover ritual

Once a pipeline has passed two weeks of clean parallel runs, cut over:

  1. Lock the Classic pipeline so nobody triggers it accidentally. The UI option is "Disable" in Release pipeline settings.
  2. Update the YAML pipeline to deploy to the real production resource group instead of the validation one. Single parameter change.
  3. Run the YAML pipeline once manually, watch it complete, verify production health.
  4. Document the cutover in the pipeline's PR description: which Classic ID is now retired, which YAML pipeline replaced it, who approved the cutover, link to the parallel-run comparison report.
  5. Wait one full deploy cycle (a week or so) before deleting the Classic definition. Easy rollback if something surfaces.
  6. Delete the Classic Release definition once the wait is up. The YAML pipeline is now sole owner.

The "wait one cycle before deletion" rule is the one most teams skip. The cost is low (a disabled pipeline is free), and the benefit is high (instant rollback if something subtle pops up). Stay disciplined about this.

Step 7: The migration calendar

For 83 pipelines and a six-month deadline, the math is roughly four pipelines per week. The phased plan looks like:

Week Goal Throughput
1 Inventory + classification + tooling 0 pipelines migrated
2 First 4 easy pipelines (parallel runs start) 0 cut over
3-4 4 more easy + 2 medium per week First 4 easy cut over
5-6 All 25 easy pipelines should have cut over Now working through medium
7-12 Medium pipelines, 4 per week Medium tier completing
13-20 Hard pipelines, 1-2 per week The long tail
21-24 Slack for surprises Final cleanup

The hard pipelines take longer per unit because the Classic export tool fails on most of them; you're rewriting from scratch. Plan for one engineer-day per hard pipeline. Plan for half a day per medium. Plan for two hours per easy.

docs/migration-calendar.md should track:

  • Each pipeline's owner (a human who has the context to validate the cutover)
  • Scheduled migration week
  • Current status (Inventoried, Drafted, Parallel-run, Cut over, Verified, Deleted)
  • Notes (any unusual behaviour discovered during parallel runs)

A weekly migration sync (30 minutes, all owners) keeps everyone calibrated. Skip it for one week and the calendar drifts; we made this mistake once.

Step 8: The patterns the converter does not handle

These are the Classic features the export tool either doesn't support or does badly. Plan for manual rewrite:

Deployment groups (on-prem targets): The exporter emits a stub that doesn't connect to any real targets. You'll need to migrate to Environments with virtual machine resources in YAML, which is the modern replacement. Documented at Environments, virtual machine resources.

Custom gates (REST webhook, Azure Function, query work items): The exporter drops these. Reimplement as azure-pipelines-tasks/AzurePipelinesAgent@1 invocations of small scripts that do the same call. For ServiceNow gates specifically, the Azure Pipelines ServiceNow extension is the supported path.

Variable groups linked to Azure Key Vault: The exporter gets the variable group reference but loses the Key Vault binding. Recreate it manually, or replace with AzureKeyVault@2 task in the YAML.

Task groups (your custom-built reusable steps): The exporter inlines task group contents, which works but loses the abstraction. Migrate task groups to YAML templates separately, then have pipelines extend the templates.

Approval gates with multiple approvers and "any one of N" semantics: The exporter emits a basic approval; the multi-approver semantics are lost. Re-implement using environment approval policies in YAML with the right approver list. Documented at Approvals and checks.

Step 9: The post-migration cleanup

Once all 83 are migrated:

  1. Audit for remaining Classic objects. vsrm.dev.azure.com/.../release/definitions?api-version=7.1 should return zero results.
  2. Disable Classic Release in the org under Organisation Settings → Pipelines → Settings → Disable creation of classic build pipelines / classic release pipelines.
  3. Document the migration retrospective. What surprises came up. Which patterns the converter handled badly. Which pipelines took longer than estimated and why. This is the gift you give the next team that does this migration.
  4. Update onboarding docs to remove every reference to Classic Release. New engineers should only see YAML.

Production checklist

  1. Don't migrate alone. One engineer can technically do it; one engineer plus one reviewer per pipeline catches more parallel-run deltas than one engineer reading their own report.

  2. Keep the Classic disabled-but-undeleted for a week per pipeline. Rollback is cheap when the Classic still exists; rollback is a re-creation when it doesn't.

  3. Set a hard deadline early. Microsoft's deprecation timeline is the calendar; align the migration to it with a buffer of at least a month.

  4. Run the inventory snapshot weekly. A pipeline that appears in week 5 (because someone created it after the migration started) is a normal occurrence. The snapshot catches it.

  5. Get the security team's sign-off on the YAML template. The template is what every migrated pipeline inherits; baking in the right defaults (workload identity federation, environment gates, what-if previews) means every migrated pipeline is more secure than its Classic predecessor.

Troubleshooting

Classic export endpoint returns 200 with an empty file. The pipeline uses features the converter doesn't support. Plan for manual rewrite. Common culprits: deployment groups, custom gates, complex variable transforms.

YAML pipeline succeeds but produces a different artefact than Classic. Almost always a build-step difference. Compare the actual dotnet publish or npm run build invocations side by side; small parameter differences (release vs debug, framework target, output path) compound.

Approval emails go to the wrong people in YAML. Environment approver lists in YAML don't inherit Classic approver lists. Configure each environment's approval rules manually, double-check before cutover.

Variable group binding fails after migration. Classic pipelines could reference variable groups across projects; YAML cannot. If a pipeline depends on a cross-project variable group, you need to copy the group into the project hosting the YAML, then update references.

Service connection authentication fails after migration. Classic and YAML pipelines authenticate to Azure differently in some cases. If the Classic was using a deprecated auth mode (e.g. AAD without MFA), the YAML's modern auth will fail. Re-create the service connection with workload identity federation as part of the migration.

Real-world references

The Microsoft Learn migration page and the deprecation Q&A are the two pages every engineer doing this should bookmark first. Everything else flows from those.

What this gives you, beyond meeting the deadline

The obvious win is being on the supported pipeline shape before the deprecation date. You stop being a deprecated-tech liability for the SOC 2 audit; the security team's pipeline-related findings list shrinks.

The less obvious wins compound over years:

  • Pipelines are now in Git. Code review applies. Diffs are visible. Rollback is a git revert.
  • Templates eliminate copy-paste. A 200-line Classic JSON becomes a 12-line YAML that extends a template. Multiply across 83 pipelines and the maintenance burden drops by an order of magnitude.
  • Pipelines are deployable infrastructure. A new project can start from "extends the canonical template" in five minutes; before, it was hours of clicking through the Classic UI.
  • Workload identity federation becomes feasible. Classic pipelines couldn't easily use WIF; YAML pipelines can. The migration is the natural moment to also retire long-lived service principal secrets.

The cultural shift is what most teams underestimate. Pre-migration, pipeline changes were rare and risky because they happened in a UI nobody was comfortable with. Post-migration, pipeline changes go through PRs like any code change. The pipeline becomes editable by people other than the platform team's senior engineer, which redistributes the work.

The team I shipped this for finished the migration in 23 weeks, three weeks before the deprecation deadline. Eighty-three Classic pipelines became 71 YAML pipelines (we found 12 redundant ones during the migration and retired them outright). Total parallel-run incidents that needed real investigation: 6. Total post-cutover production incidents: 1. The cost of the migration was an engineer-and-a-half for a quarter; the benefit is paid every week thereafter, in faster pipeline iteration and a security posture the audit team is happy with.

Classic PipelinesYAML MigrationAzure DevOps

Conversation

Reactions & comments

Liked this? Tap a reaction. Want to push back, share a war story, or ask a follow-up? Drop a comment below — replies are threaded and markdown works.

Loading conversation…

More from DevOps

See all →