Migrating from Classic Release Pipelines to YAML and What Changed in Azure Pipelines from 2023 to 2026
Classic release pipelines can't be exported to YAML; builds start from Export to YAML, while releases are rebuilt by hand with stages, checks and templates. For Bookstore we turn a task group into a step template and redeploying an old release into a pipeline with a pipeline resource, disable classic creation, and collect the 2023–2026 changes, the roadmap and the decision to stay on Azure DevOps, with dates.
Version note: The pipeline and template files in this post were parsed and checked with yq 4.54. The description of classic pipelines, the migration tools and the changes between 2023 and 2026 is based on the official documentation on Microsoft Learn, the Azure DevOps release notes and the roadmap page (November 2026 versions).
Guess First
For years, Hakan's team has shipped an older copy of Bookstore, the online bookstore used as the shared example across this site's series, with classic pipelines: a classic build, a four-stage classic release fed by it, and a scheduled build that runs at 02:00 every weeknight. The plan has two steps: export both the build and the release with "Export to YAML", and move the scheduled build over as cron: "0 2 * * 1-5". Do both steps work as intended?
Neither does. According to the documentation, exporting to YAML exists only for pipelines created in the classic build designer; classic release pipelines have no export, and their tasks must be moved one by one. The schedule shifts as well: classic pipelines use the organization's local time zone, while cron in YAML is UTC. A build that ran at 02:00 Istanbul time starts running at 05:00 if it moves with the same cron. The right equivalent is 23:00 UTC from Sunday to Thursday, that is "0 23 * * 0-4". In this post we pull together details like these, on top of the structure the series has built.
Reading the Old Setup and the Migration Map
A migration has to start with an inventory, because on the classic side the information isn't in one file as in YAML but spread across many places in the web UI. For each classic build and release we look at: which artifacts it consumes, its stages and their order, each stage's pre- and post-deployment conditions (approvals, gates, schedules), the task groups and deployment groups it uses, release and stage variables, and which service connections it touches. Once the inventory is done, the mapping looks like this:
| Classic | YAML counterpart in Bookstore | Note |
|---|---|---|
| Build definition | azure-pipelines.yml + the pipelines/bookstore-ci.yml skeleton | Export to YAML can be the starting point |
| Release definition and its stages | Deploy_dev, Deploy_staging, Deploy_production in the skeleton | No export; written by hand |
| Build artifact | The release pipeline artifact | Build once, deploy many |
| Task group | Step template (steps/smoke-test.yml) | Task groups aren't supported in YAML |
| Pre-deployment approval and gates | Environment and service connection checks | The table in the sixth post |
| Release and stage variables | Variable groups and the group linked to Key Vault | Secrets don't move into the file |
| Redeploying an old release to a stage | azure-pipelines.redeploy.yml | With a pipeline resource |
| Scheduled build/release | schedules cron | UTC; local time in classic |
| Deployment group | Environment with virtual machine resources | Not needed, since Bookstore uses App Service |
The file Export to YAML gives you is a starting point. The documentation says variables defined in the classic editor don't move into the file and have to be defined again in the YAML or in the pipeline settings. For us, the real work was putting the exported steps inside the shared skeleton, the templates and the checks anyway; we did that in the first eight posts of the series.
From Task Groups to Step Templates
In classic, a task group turns several tasks into a single reusable task; values of the form $(MyVariable) in the tasks become the task group's parameters. The documentation says task groups aren't supported in YAML and templates should be used instead. We turned the "Bookstore - Smoke test" task group of the old Bookstore into a step template:
# Replaces the classic task group "Bookstore - Smoke test": same steps, parameters instead of $(variables).
parameters:
- name: baseUrl
type: string
- name: attempts
type: number
default: 10
- name: delaySeconds
type: number
default: 15
steps:
- script: bash scripts/smoke-test.sh ${{ parameters.baseUrl }}
displayName: Smoke test
target:
settableVariables: none
env:
SMOKE_ATTEMPTS: ${{ parameters.attempts }}
SMOKE_DELAY_SECONDS: ${{ parameters.delaySeconds }}
The deploy template no longer calls the script directly; it uses this template:
- template: ../steps/smoke-test.yml
parameters:
baseUrl: $(deployApi.AppServiceApplicationUrl)
Two differences matter. In a task group the parameters are text; a template has types such as number and lists of allowed values, so a wrong value is caught while the file is processed. A central change to a task group reaches every definition that uses it immediately; with a template, the change comes in a new tag and each pipeline gets it when it moves its ref. That second point may look like a limitation, but in the third post we chose version pinning on purpose: a template change can't break every pipeline at once.
Redeploying an Old Release
A frequently used feature of classic releases is picking a past release and deploying it to a stage again. In YAML the counterpart is a separate pipeline: it defines the CI pipeline (named bookstore-ci in Azure DevOps) as a pipeline resource and asks for the environment with a runtime parameter. The environment selection we saw in the second post finds its real place here:
# Redeploys the release artifact of an earlier bookstore-ci run to one environment.
# Pick the run under "Resources" when you start this pipeline; checks of the environment still apply.
trigger: none
pr: none
parameters:
- name: environment
displayName: Environment
type: string
default: staging
values:
- dev
- staging
- production
resources:
repositories:
- repository: templates
type: git
name: Bookstore/pipeline-templates
ref: refs/tags/v1.6.0
pipelines:
- pipeline: ci
source: bookstore-ci
trigger: none
extends:
template: pipelines/bookstore-redeploy.yml@templates
parameters:
environment: ${{ parameters.environment }}
According to the documentation, when you start the run by hand you can pick the run you want for the ci resource under Resources; if you don't, the latest successful CI run is used. The new skeleton (pipelines/bookstore-redeploy.yml) produces a single deploy stage and tells the deploy template where the artifact comes from:
stages:
- template: ../stages/deploy.yml
parameters:
environment: ${{ parameters.environment }}
dependsOn: []
mainOnly: ${{ eq(parameters.environment, 'production') }}
useSlot: ${{ eq(parameters.environment, 'production') }}
artifactPipeline: ${{ parameters.artifactPipeline }}
When artifactPipeline isn't current, the deploy template changes two things: it downloads the artifact with download: ci and uses $(Pipeline.Workspace)/ci/release as the folder (according to the documentation, pipeline resource artifacts land under <pipeline-identifier>/<artifact-identifier>). The expected commit becomes $(resources.pipeline.ci.sourceCommit). This is where the commit check from the sixth post pays off: if the chosen run's artifact came from a different commit, the verification step stops the deployment.
There's one more step for the checks. We add pipelines/bookstore-redeploy.yml as a second template to the required template check on the bookstore-production environment and on the bookstore-azure-production connection; otherwise the redeploy pipeline can't enter production. Approval, branch control and the exclusive lock still apply. This version of the templates is v1.6.0.
Running in Parallel and Switching Classic Off
Instead of migrating in one go, we ran both paths side by side for a while. In the first week the YAML pipeline deployed only to dev and staging, while production stayed on the classic release; we compared the packages the same build produced through both paths using the commit and file counts in the manifest. On the day we moved production to YAML we disabled the classic release's triggers but didn't delete the definition; it was there if we needed to go back. Two sprints later we deleted the classic definitions.
The last step is preventing classic from coming back. In organization or project settings, "Disable creation of classic build pipelines" and "Disable creation of classic release pipelines" can be turned on separately; with both on, classic builds, classic releases, task groups and deployment groups can't be created from the UI or the REST API. The documentation's reason is security: because YAML and classic pipelines can share the same service connections, a classic definition can open a way around the checks in YAML. Since Sprint 226, this setting is on by default for new organizations. Classic releases also have their own job authorization scope setting; until the migration is done, narrow that to the project as well.
What Changed in Azure Pipelines from 2023 to 2026
Let's collect the changes the series relied on, in date order. Each item comes from the release notes or the relevant documentation page:
- August 2023, Sprint 226: creating classic builds and releases is disabled by default for new organizations (pre-announced in Sprint 225).
- February 2024, Sprint 234: workload identity federation for Azure Resource Manager service connections becomes generally available.
- November 18, 2024: Managed DevOps Pools announced as generally available; the Sprint 251 release notes list the GA too. According to the roadmap, new features go there, not to Scale Set pools.
- March 2025, Sprint 253: new Azure and Docker service connections are created with the Microsoft Entra issuer.
- December 31, 2025: the
windows-2019hosted image is retired. - July 1, 2026: the Azure DevOps issuer is deprecated; it retires on July 1, 2027.
- September 29, 2026: the
ubuntu-26.04andwindows-2025-vs2026images become generally available;ubuntu-latestis currentlyubuntu-24.04, andubuntu-22.04will be removed on April 17, 2027. - November 24, 2026: Node.js 6, 10 and 16 are removed from the agent; the agent includes Node.js 24, and moving the official tasks to Node.js 24 is an undated roadmap item.
- 2027: public projects are converted to private.
The roadmap's "YAML and release pipelines feature parity" section states Microsoft's direction plainly: in recent years all pipeline investments and all security improvements went into YAML, and the goal is to close the CD gaps that make moving from classic releases to YAML hard. Two items in that section touch gaps this series cares about. "On-demand out of order execution of stages", starting a later stage by hand without waiting for its dependencies, appears as 2027 Q1. "Stage-level traceability", a stage-focused view that shows which run last executed a stage, appears in two places on the page with two different timeframes: under 2026 Q4 in the all-features table, and as 2027 Q1 in that section's own table. "Service connections in checks" and "Checks extensibility" are undated "Future" items.
A Decision Note: Stay on Azure DevOps or Move to GitHub Actions
A team leaving classic also asks this: since we're migrating anyway, should we go straight to GitHub Actions? Let's compare the two options.
Staying on Azure DevOps and moving to YAML: existing integrations with Boards, Repos and Artifacts, the permissions and the Entra ID setup stay in place; the migration covers only the pipelines. Microsoft keeps investing too: on the roadmap, Copilot code review for Azure Repos reached public preview in 2026 Q3, GitHub Advanced Security for Azure DevOps has items planned for 2026 Q4, and Managed DevOps Pools gets new features regularly. The cost is that some CD features are still waiting on the roadmap.
Moving to GitHub Actions: code and CI live in one place, and the marketplace and community are large. But this isn't a pipeline migration, it's a platform migration: repositories, permissions, package feeds and work tracking move together. The one-to-one mapping of concepts is in the table in the first post; Actions itself is covered in this site's GitHub Actions series. For the Bookstore team the decision was to stay on Azure DevOps: the migration's risk stayed limited to the pipelines, and the skeleton, checks and federated connections built in this series already sit on top of the YAML investment.
A note for Azure DevOps Server users: this series covered Services. According to the documentation, Microsoft-hosted agents don't exist on Server, so you need self-hosted agents. The Server version of the Azure service connection documentation describes a service principal that works with a secret or a certificate; the workload identity federation sections are written for Services. For agent registration on Azure DevOps Server 2022, the documentation lists PAT and Alternate authentication.
In Production
Export the history before you delete a classic definition. Who made a release, with which artifact and which approval can be needed for audits; the environment history on the YAML side starts on migration day.
You may not be able to move every gate one to one. The Query work items and Azure Policy compliance gates have no check counterpart; you either move them to your own service behind an Invoke REST API check or take them out of the process.
When converting schedules to UTC, don't forget countries that use daylight saving time. UTC stays fixed while local time changes; if you work with such an office, you may need to review the cron twice a year.
Common Pitfalls
Expecting to export the release as well. Export to YAML exists only for pipelines from the classic build designer, and it shows up on the definition itself, not on a specific run.
Forgetting the classic variables. The exported file doesn't contain the variables defined in the classic editor; the first YAML run may quietly do the wrong thing with an empty variable.
Leaving classic switched on. If classic creation isn't disabled after the migration, someone can open a new classic release and use the same service connection outside the checks in YAML.
Forgetting to add the redeploy pipeline to the required template check. Even when the pipeline is written correctly, it gets stuck on the check in production; the error points at a missing template, not at the pipeline itself.
A Small Experiment
★☆☆ Write the YAML cron expression for a build that must run at 07:30 Istanbul time every weekday morning.
★★☆ Take an inventory of a classic release using the table in this post: stages, approvals, gates, task groups, variables, service connections. Write the YAML counterpart of each row.
★★★ Add a second runtime parameter to the redeploy pipeline that lets you deploy only the API or only the web app. Do it with conditional insertion in the deploy template, and think about how the production checks are affected.
The series ends here. Over nine posts we wrote Bookstore's pipelines as code; in the web UI we only defined resources, permissions and checks: shared templates, .NET and Angular CI, an Azure Artifacts feed, deploy stages with approvals and checks, passwordless Azure connections and tightened agent settings. If you want to go further, provisioning Azure resources as code is the subject of the Infrastructure as Code: Terraform series, and dependency and code scanning belong to the DevSecOps series.