GitHub

RFC: YAML Pipeline Migration for Release Pipelines

Field Value
Author Miko Hadikusuma
Status Implementation Complete
Updated 2026-03-20
Created 2026-02-25
Context Azure DevOps Classic Release Pipelines

Table of Contents

Summary

Replaced ~200+ per-tenant Classic Release pipelines across 5 services (WebApp, IdentityServer, InterfaceTaskService, Database, SyncTenant) with 21 parameterized YAML pipelines checked into the healthAlignPMS repo. Per-tenant configuration is stored in YAML variable files, and all pipeline logic is version-controlled via reusable templates.

Motivation

Each tenant required its own cloned Classic Release pipeline because:

  1. SSH service connections (sshEndpoint GUIDs) bind to a specific VM at design time. Classic Release pipelines do not support variable substitution for connected-service inputs.
  2. Deployment group (queueId) is a fixed reference to a single deployment group tied to one VM.

This meant every new tenant onboarding required cloning and manually editing a pipeline, and any pipeline structure change had to be replicated across 30+ definitions per environment.

YAML pipelines solve both problems: service connections are parameterized via compile-time template expressions (${{ }}), and deployment groups are replaced by YAML environment: resources with VM tags.

Current Architecture

Pipeline: WebApp-TransAmerica-Docker-UAT (Classic Release)
  Phase 1 - "Transfer .env files" (runs on ADO Deploy Helper agent, queueId: 49)
    Task: Replace .webapp environment variables
    Task: Copy .env variables to Linux VM      -> sshEndpoint: 03211b98-... (THB-N-WEB3-LINUX SSH)
    Task: Copy .env variables to Windows VM     -> sshEndpoint: 8cf5492c-... (THB-N-WEB3-WIN SSH)
    Task: Copy .caddy files to Linux VM         -> sshEndpoint: 03211b98-...
    Task: Copy compose files to Linux VM        -> sshEndpoint: 03211b98-...
  Phase 2 - "Configure Cloudflared" (runs on ADO Deploy Helper agent, queueId: 49)
    Task: Pull cloudflared certificate / tunnel from GCP
    Task: Copy cert/tunnel/config to Linux VM
    Task: Cleanup temp files
  Phase 3 - "Deployment group job" (deployment group queueId: 116 = THB-N-WEB3-LINUX)
    Task: Get Short Commit ID
    Task: PowerShell - deploy_webapp_all.ps1

Other services (IdentityServer, InterfaceTaskService, Database, SyncTenant) follow similar patterns with variations in env files, vault secrets, and deploy scripts.

Tenant-to-VM Mapping

UAT (non-production)

4 VM clusters. Domain: myhaapp.com

VM Cluster Linux VM Windows VM Tenants
WEB1 THB-N-WEB1-LINUX THB-N-WEB1-WIN statefarm, networkhealth, humana, massmu, prudential, unum, verda, scan, goldkidney, genworth
WEB2 THB-N-WEB2-LINUX THB-N-WEB2-WIN ultimate, aarp, aetna, alignment, bcbs-ar, centene, cgi, elderplan, abilishealth, healthallianceplan
WEB3 THB-N-WEB3-LINUX THB-N-WEB3-WIN bcbs-az, bcbs-sc, champion, clevercare, core, jh, moo, transamerica, uhc, allyalign
WEB4 THB-N-WEB4-LINUX THB-N-WEB4-WIN identity, isaachealth, longevity, lynx, prupeak, sompo, cna, demo, simpra, thb

Production

8 VM clusters (no WEB8). Primary domain: myhomealign.com. Exception: Moo uses betterlivinglonger.com.

VM Cluster Linux VM Windows VM Tenants
WEB1 THB-P-WEB1-LINUX THB-P-WEB1-WIN genworth, scan, goldkidney, verda, unum
WEB2 THB-P-WEB2-LINUX THB-P-WEB2-WIN humana, statefarm, prudential, networkhealth, massmu
WEB3 THB-P-WEB3-LINUX THB-P-WEB3-WIN elderplan, abilishealth, healthallianceplan, alignment, ultimate
WEB4 THB-P-WEB4-LINUX THB-P-WEB4-WIN centene, aarp, aetna, bcbs-ar, cgi
WEB5 THB-P-WEB5-LINUX THB-P-WEB5-WIN bcbs-az, bcbs-sc, champion, core, clevercare
WEB6 THB-P-WEB6-LINUX THB-P-WEB6-WIN jh, moo, uhc, allyalign, transamerica
WEB7 THB-P-WEB7-LINUX THB-P-WEB7-WIN isaachealth, longevity, lynx, prupeak, sompo
WEB9 THB-P-WEB9-LINUX THB-P-WEB9-WIN cna, identity, demo, simpra, thb

SSH Service Connections (managed by Terraform)

Terraform creates SSH service connections via azuredevops_serviceendpoint_ssh.cluster_ssh with the naming convention {VM_NAME} SSH (e.g., THB-N-WEB3-LINUX SSH). YAML pipelines reference these by name instead of GUID, resolved at compile time via ${{ }} expressions.

ADO Agents

  • Staging: ADO Deploy Helper agent pool (non-production GCP agent)
  • Production: ADO Deploy Helper - Prod agent pool (production GCP agent)

Implemented Architecture

Key Design Decisions

  1. Phases 1+2 merged into a single job. Vault secret variables and Cloudflare output variables are naturally available to subsequent steps without cross-job plumbing.

  2. Artifact via resources.pipelines. Downloaded with - download: {pipeline} at $(Pipeline.Workspace)/{pipeline}/. Only environment artifact is downloaded (not drop) except for Database which needs both.

  3. Deploy stage uses environment: with resourceType: virtualMachine and tags. Replaces Classic deployment groups. VMs are re-registered in YAML environments (separate from Classic deployment groups). 2 shared environments (Staging, Production) with all VMs tagged by hostname. Production has approval gates.

  4. Artifact source commit passed via file on VM. Pipeline resource variables aren’t available on environment VM agents, and ADO keeps hyphens in env var names. The workaround: env | grep -i 'RESOURCES_PIPELINE_.*_SOURCECOMMIT' extracts the commit, writes to /DevOps/.artifact_commit_$(Build.BuildId) via SSH, deploy step reads it.

  5. Separate staging and production entry-point files. Staging uses latest build from any branch; production restricts to release branch via resources.pipelines.branch filter.

  6. Separate agent pools per environment. Staging uses ADO Deploy Helper (queueId 49), production uses ADO Deploy Helper - Prod (queueId 72).

  7. Token replacement preserves unresolved tokens. replacetokens@6 with tokenPattern: 'azpipelines' and missingVarAction: 'keep' — only vault secrets are replaced, container-consumed tokens like $(server) are left intact.

File Structure

healthAlignPMS/.azuredevops/
  pipelines/
    webapp/
      release-staging.yml, release-production.yml
      templates/release-stages.yml, steps/{transfer-env-files,deploy}.yml
    identityserver/
      release-staging.yml, release-production.yml
      templates/release-stages.yml, steps/{transfer-env-files,deploy}.yml
    interfacetaskservice/
      release-staging.yml, release-production.yml
      templates/release-stages.yml, steps/{transfer-env-files,deploy}.yml
    database/
      release-staging.yml, release-production.yml
      templates/release-stages.yml, steps/{transfer-files,deploy}.yml
    synctenant/
      release-staging.yml, release-production.yml
      enable-staging.yml, enable-production.yml
      disable-staging.yml, disable-production.yml
      update-client-staging.yml, update-client-production.yml
      update-server-client-staging.yml, update-server-client-production.yml
      one-time-sync-production.yml
      templates/release-stages.yml, steps/{transfer-files,deploy}.yml
    shared/
      steps/configure-cloudflared.yml
  tenant-configs/
    staging/    # 39 WebApp + 1 IdentityServer = 40 configs
    production/ # 39 WebApp + 1 IdentityServer = 40 configs

Pipelines Overview

21 YAML pipelines registered in ADO:

Service ADO Pipeline Name YAML File Artifact Branch
WebApp WebApp-Release-Staging webapp/release-staging.yml Any
WebApp WebApp-Release-Production webapp/release-production.yml release
IdentityServer IdentityServer-Release-Staging identityserver/release-staging.yml Any
IdentityServer IdentityServer-Release-Production identityserver/release-production.yml release
InterfaceTaskService InterfaceTaskService-Release-Staging interfacetaskservice/release-staging.yml Any
InterfaceTaskService InterfaceTaskService-Release-Production interfacetaskservice/release-production.yml release
Database Database-Release-Staging database/release-staging.yml Any
Database Database-Release-Production database/release-production.yml release
SyncTenant SyncTenant-Release-Staging synctenant/release-staging.yml Any
SyncTenant SyncTenant-Release-Production synctenant/release-production.yml release
SyncTenant SyncTenant-Enable-Staging synctenant/enable-staging.yml
SyncTenant SyncTenant-Enable-Production synctenant/enable-production.yml
SyncTenant SyncTenant-Disable-Staging synctenant/disable-staging.yml
SyncTenant SyncTenant-Disable-Production synctenant/disable-production.yml
SyncTenant SyncTenant-Update-Client-Staging synctenant/update-client-staging.yml
SyncTenant SyncTenant-Update-Client-Production synctenant/update-client-production.yml
SyncTenant SyncTenant-Update-Server-Client-Staging synctenant/update-server-client-staging.yml
SyncTenant SyncTenant-Update-Server-Client-Production synctenant/update-server-client-production.yml
SyncTenant SyncTenant-One-Time-Sync-Production synctenant/one-time-sync-production.yml

Service comparison:

Aspect WebApp IdentityServer InterfaceTaskService Database SyncTenant
Artifact WebApp-Docker IdentityServer-Linux-Docker InterfaceTaskService-Docker Database SyncTenant-Linux-Docker
Vault secrets Yes No Yes No No
Cloudflare Yes Yes No No No
Windows VM copy Yes No Yes No No
Token replacement .webapp, .interfacetaskwebtest No .interfacetaskservice, .interfacetaskwebtest No No
Deploy script deploy_webapp_all.ps1 deploy_identityserver_caddy.ps1 deploy_interfacetaskservice_all.ps1 deploy_database.ps1 deploy_synctenant.ps1
Needs commit ID Yes Yes Yes No Yes
Per-tenant Yes (39) No (single) Yes (39) Yes (39) No (single)

Shared Templates

shared/steps/configure-cloudflared.yml — Used by WebApp and IdentityServer. Takes an artifactName parameter. Steps: pull cert from GCP, copy to VM, pull tunnel, copy to VM, cleanup.

Per-Tenant Variable Files

Each per-tenant service (WebApp, InterfaceTaskService, Database) shares the same tenant config files:

# .azuredevops/tenant-configs/staging/transamerica.yml
variables:
  envDir: '.transamerica-staging'
  domain: 'myhaapp.com'
  tenantUrl: 'https://transamerica.myhaapp.com'
  tunnelSecret: 'tunnel_myhaapp_com_to_transamerica_admin_portal'
  linuxSshEndpoint: 'THB-N-WEB3-LINUX SSH'
  windowsSshEndpoint: 'THB-N-WEB3-WIN SSH'
  deploymentEnvironment: 'Staging'
  deploymentTag: 'THB-N-WEB3-LINUX'
  agentPool: 'ADO Deploy Helper'

Production configs use agentPool: 'ADO Deploy Helper - Prod'.

Single-service pipelines (IdentityServer, SyncTenant) have config values inlined in their entry-point files.

Key Advantages Over Classic

Aspect Classic YAML
Service connections Hardcoded GUIDs Named connections, resolved at compile time
Deployment targets Hardcoded queueId per deployment group environment: with VM tags
Tenant config Manual variable overrides at release time Version-controlled YAML files per tenant
Pipeline definition Stored in ADO UI only Checked into repo, versioned with code
Reusability ~200+ cloned pipeline definitions 21 parameterized YAML pipelines
New tenant onboarding Clone pipeline, edit 6+ variables Add one YAML file, create a PR
Audit trail ADO revision history only Full git history
ADO location Pipelines/Releases Pipelines (YAML has no separate “release” concept)

Implementation Plan

All phases complete.

Phase 1: Scaffold YAML pipeline structure — DONE

Created .azuredevops/pipelines/ directory structure for all 5 services with staging/production entry-points, stage templates, step templates, and shared Cloudflare template.

Phase 2: Create per-tenant config files — DONE

Generated 80 tenant config files (39+39 WebApp, 1+1 IdentityServer). All tunnelSecret values verified against Classic pipeline variables. Removed defunct tenants (humanabenefits, uch).

Phase 3: Set up Azure DevOps environments — DONE

Created 2 shared YAML environments (Staging, Production) with all VMs registered and tagged by hostname. Production environment has approval gates. All SSH service connections authorized.

Phase 4: Validate on staging — DONE

Validated all services on staging: WebApp (transamerica on WEB3, unum on WEB1), IdentityServer (WEB4), InterfaceTaskService (transamerica), Database (transamerica), SyncTenant (WEB4).

Phase 5: Validate on production — DONE

Validated all services on production with approval gates and release branch enforcement.

Phase 6: Cleanup — REMAINING

  1. Retire Classic per-tenant pipeline definitions
  2. Update onboarding documentation: new tenant = new YAML config file + PR
  3. Deprecate LaunchBot Classic pipeline generation

Lessons Learned

  • YAML environments and Classic deployment groups are separate systems. VMs must be re-registered. Both agents coexist on the same VM during migration.
  • resourceType: virtualMachine is required. Without it, ADO defaults to a hosted agent.
  • Pipeline resource variables with hyphens don’t resolve via $() macro. ADO keeps hyphens in env var names. Use env | grep to extract.
  • replacetokens@6 defaults replace missing tokens with empty strings. Must set missingVarAction: 'keep' and tokenPattern: 'azpipelines'.
  • Staging and production need separate agent pools. ADO Deploy Helper (staging) vs ADO Deploy Helper - Prod (production).
  • Disable CI/PR triggers in ADO UI settings — YAML trigger: none and pr: none aren’t always sufficient.
  • Download only needed artifactsartifact: environment plus download: none + checkout: none prevents redundant downloads.

Future Considerations

  • Automate environment setup: Manage via azuredevops Terraform provider or ADO REST API.
  • Automation from infrahive: Generate tenant config YAML files from tfvars automatically.
  • Multi-tenant batch deploys: Matrix strategy for parallel deployment of multiple tenants on the same VM.
  • Pipeline triggers: Auto-trigger on build completions once stable.
  • LaunchBot deprecation: Replace Classic pipeline generation with YAML config generation.
Edit this page