iOS CI/CD with Azure DevOps: The Complete Production Guide

January 01, 9999

From Zero to Automated App Store Deployments Using Azure Pipelines โ€” A Beginner-Friendly Deep Dive


Who This Guide Is For

You are an iOS developer (or a team that uses Azure DevOps for project management) who wants to automate the entire build-test-deploy lifecycle of an iOS app using Azure Pipelines. Maybe your organization already uses Azure DevOps for boards, repos, and artifacts, and you want your iOS CI/CD to live in the same ecosystem. Maybe you are evaluating Azure against GitHub Actions or Bitrise and want to see the full picture before committing.

This guide assumes you are relatively new to CI/CD. Every concept is explained from first principles โ€” what it is, why it exists, and exactly how to configure it. By the end, a git push will trigger a pipeline that builds your app, runs every test, signs the binary, uploads it to TestFlight, and posts a notification to your team โ€” all without you opening Xcode.

Prerequisites you should have:

  • A working Xcode project that builds and runs locally
  • Basic Git knowledge (commit, push, pull, branches)
  • A paid Apple Developer account ($99/year โ€” required for code signing and distribution)
  • An Azure DevOps organization and project (free tier is fine to start)
  • Basic terminal/command-line comfort

What you do NOT need:

  • Prior CI/CD experience on any platform
  • Ruby knowledge (Fastlane uses Ruby, but you will treat it like a configuration tool)
  • Azure cloud infrastructure experience (we use Azure DevOps, not Azure cloud VMs)
  • Deep knowledge of YAML (we explain every line)

Table of Contents

  1. Chapter 1: Azure DevOps for iOS โ€” Why and When
  2. Chapter 2: Azure DevOps Concepts Explained from Scratch
  3. Chapter 3: iOS Code Signing Deep Dive (Azure-Specific)
  4. Chapter 4: Setting Up Your Azure DevOps Project
  5. Chapter 5: Fastlane Setup for Azure Pipelines
  6. Chapter 6: Your First Pipeline โ€” Build and Test on Every PR
  7. Chapter 7: Code Signing in Azure Pipelines
  8. Chapter 8: Staging Pipeline โ€” Automated TestFlight Deploys
  9. Chapter 9: Production Pipeline โ€” App Store Deployment
  10. Chapter 10: Variable Groups, Secure Files, and Secrets Management
  11. Chapter 11: Environments, Approvals, and Gated Releases
  12. Chapter 12: Optimizing Build Performance
  13. Chapter 13: Templates and Reusable Pipeline Components
  14. Chapter 14: Integrating Azure Boards with Pipelines
  15. Chapter 15: Troubleshooting Guide
  16. Chapter 16: Complete Reference and Cheat Sheets

Chapter 1: Azure DevOps for iOS โ€” Why and When

1.1 What Is Azure DevOps?

Azure DevOps is Microsoft's suite of developer collaboration tools. Unlike GitHub (which is primarily a code hosting platform with CI bolted on), Azure DevOps is a full-spectrum project management and delivery platform that includes five integrated services:

Service What It Does iOS Relevance
Azure Boards Agile project management โ€” work items, sprints, backlogs, Kanban boards Track features, bugs, and releases. Link work items to builds and deployments.
Azure Repos Git repository hosting (similar to GitHub) Host your iOS project's source code. Supports both Git and TFVC.
Azure Pipelines CI/CD pipelines (the star of this guide) Build, test, sign, and deploy your iOS app automatically.
Azure Test Plans Manual and exploratory testing Plan and track QA testing sessions for your iOS app.
Azure Artifacts Package hosting (NuGet, npm, Maven, Python, Universal) Host internal frameworks, shared libraries, or build artifacts.

You can use all five services together or pick only the ones you need. Many teams use Azure Boards for project management and Azure Pipelines for CI/CD while keeping their code on GitHub โ€” Azure Pipelines natively supports GitHub repositories.

1.2 Why Choose Azure Pipelines for iOS?

There are several CI/CD platforms that support iOS. Here is how Azure Pipelines compares:

Factor Azure Pipelines GitHub Actions Xcode Cloud Bitrise
macOS hosted agents Yes (macOS 13, 14, 15) Yes (macOS 13, 14, 15) Yes (Apple-managed) Yes
Free tier 1 free parallel job, 1,800 minutes/month for public projects 2,000 min/month (200 macOS min at 10x multiplier) 25 compute hours/month 90 builds/month
Self-hosted agents Unlimited free parallel jobs Unlimited Not supported Limited
YAML pipelines Yes Yes No (GUI + scripts) Yes (bitrise.yml)
Approvals and gates Rich โ€” multi-stage, environment approvals, business-hours gates Basic โ€” environment approvals Basic Basic
Azure Boards integration Native โ€” automatic work item linking Via marketplace actions None None
Enterprise features Audit logs, compliance, RBAC, retention policies Similar Limited Limited
Code signing Secure Files + Install Certificate tasks GitHub Secrets + Fastlane Match Automatic (Apple-managed) Code signing management
Template system Powerful โ€” extends, parameters, conditional insertion Reusable workflows (simpler) None Step bundles

Choose Azure Pipelines when:

  • Your organization already uses Azure DevOps for project management (Boards, Repos)
  • You need enterprise-grade approval workflows with multi-stage gates
  • You need deep integration between work items, code changes, and deployments
  • You want the flexibility of both Microsoft-hosted and self-hosted macOS agents
  • You are building for multiple platforms (iOS + Android + web) and want one CI system for everything
  • You need audit trails and compliance reporting

1.3 Azure Pipelines Pricing for iOS

Understanding the pricing is important because macOS builds are expensive everywhere:

Agent Type Cost Parallelism Best For
Microsoft-hosted macOS Free tier: 1 parallel job, limited minutes. Paid: ~$40/month per parallel job + $0.08/min compute Scales automatically Teams that build occasionally (< 50 builds/month)
Self-hosted macOS Free (unlimited parallel jobs for self-hosted) โ€” you pay for your own hardware As many agents as you set up Teams that build frequently (> 50 builds/month)

The critical insight: self-hosted agents are free in Azure Pipelines. You only pay for the Mac hardware itself. If you have a Mac Mini sitting in a closet, you can turn it into a CI agent for $0 ongoing cost. This is one of Azure Pipelines' strongest advantages for iOS teams.

1.4 Key Terminology Map

If you are coming from GitHub Actions, here is how Azure terms map:

GitHub Actions Azure Pipelines What It Means
Workflow (.yml file) Pipeline (azure-pipelines.yml) The complete automation definition
Job Job A set of steps that run on one agent
Step Step / Task A single unit of work
Action (uses:) Task (task:) A reusable, pre-built step
Runner Agent The machine that runs the pipeline
Secret Secret Variable / Secure File Sensitive data (passwords, certificates)
Environment Environment A deployment target with approval rules
on: triggers trigger: / pr: What events start the pipeline
Artifact Artifact / Pipeline Artifact Files produced by a build
Matrix Strategy matrix Run the same job with different configurations
Cache (actions/cache) Cache task (Cache@2) Reuse files between pipeline runs

Chapter 2: Azure DevOps Concepts Explained from Scratch

Before we write any YAML, you need to understand the building blocks of Azure Pipelines. This chapter explains every concept you will use, with analogies to make them concrete.

2.1 Pipelines

A pipeline is the complete definition of your automated workflow. It is defined in a YAML file (typically azure-pipelines.yml) at the root of your repository.

Think of a pipeline as a recipe card. The card says: "When someone pushes code to the develop branch, here is every step to build, test, and deploy the iOS app." The pipeline file is that recipe card โ€” it lives in your repository alongside your code, so it is versioned, reviewable, and auditable.

A pipeline contains:

  • Triggers โ€” when the pipeline should run
  • Variables โ€” configuration values (some secret, some not)
  • Stages โ€” major phases of the pipeline (Build, Test, Deploy)
  • Jobs โ€” groups of steps that run on a single agent
  • Steps โ€” individual tasks (install dependencies, run tests, upload IPA)

Here is the hierarchy:

Pipeline (azure-pipelines.yml)
  โ”‚
  โ”œโ”€โ”€ Trigger: push to develop
  โ”œโ”€โ”€ Variables: APP_ID, SCHEME_NAME
  โ”‚
  โ”œโ”€โ”€ Stage: Build & Test
  โ”‚   โ””โ”€โ”€ Job: build_and_test
  โ”‚       โ”œโ”€โ”€ Step: Checkout code
  โ”‚       โ”œโ”€โ”€ Step: Select Xcode version
  โ”‚       โ”œโ”€โ”€ Step: Install dependencies
  โ”‚       โ”œโ”€โ”€ Step: Run tests
  โ”‚       โ””โ”€โ”€ Step: Publish test results
  โ”‚
  โ””โ”€โ”€ Stage: Deploy to TestFlight
      โ””โ”€โ”€ Job: deploy
          โ”œโ”€โ”€ Step: Install signing credentials
          โ”œโ”€โ”€ Step: Build IPA
          โ”œโ”€โ”€ Step: Upload to TestFlight
          โ””โ”€โ”€ Step: Notify team

2.2 Agents and Agent Pools

An agent is the machine that runs your pipeline. For iOS, this must be a Mac because xcodebuild (Apple's build tool) only runs on macOS.

Azure provides two types of agents:

Microsoft-hosted agents โ€” Virtual machines in Microsoft's cloud. They are pre-configured with macOS, Xcode, Ruby, CocoaPods, and other common tools. You do not manage them โ€” Azure creates a fresh VM for each pipeline run and destroys it afterward.

Advantages: Zero maintenance, always up-to-date, pre-installed tools. Disadvantages: Slower startup (1-2 minutes to provision), cannot customize beyond what is pre-installed, costs per-minute.

Self-hosted agents โ€” Your own Mac hardware (Mac Mini, iMac, Mac Pro, or a cloud Mac from MacStadium/AWS). You install the Azure Pipelines agent software, and your Mac becomes available as a CI runner.

Advantages: Unlimited free usage, fast startup (no provisioning), persistent caches, full customization. Disadvantages: You maintain the hardware, updates, and Xcode installations.

An agent pool is a collection of agents. When a pipeline job runs, Azure picks an available agent from the pool. You can create pools like "macOS-Hosted" (Microsoft's agents) or "Mac-Mini-Office" (your self-hosted Macs).

Which agent to use:

For this guide, we start with Microsoft-hosted macOS agents (vmImage: 'macOS-15'). They require zero setup. Later, in Chapter 12, we cover setting up self-hosted agents for teams that need better performance or cost optimization.

2.3 Stages, Jobs, and Steps

These three levels organize your pipeline:

Stage โ€” A major logical phase. Stages run sequentially by default (Build happens before Deploy). Each stage can have approval gates โ€” for example, the "Deploy to Production" stage might require a manager's approval.

Job โ€” A unit of work that runs on a single agent. Within a stage, jobs run in parallel by default (unless you add dependsOn). Each job gets a fresh agent โ€” files are NOT shared between jobs unless you explicitly pass artifacts.

Step โ€” A single task within a job. Steps run sequentially โ€” step 2 does not start until step 1 finishes. If a step fails, subsequent steps are skipped (by default).

Here is the analogy: Think of building a house.

  • Stage: Foundation โ†’ Stage: Framing โ†’ Stage: Finishing
  • Within the Foundation stage, Job: Pour concrete and Job: Install plumbing can happen in parallel.
  • Within "Pour concrete," the steps are sequential: Step: Set up forms โ†’ Step: Mix concrete โ†’ Step: Pour โ†’ Step: Level โ†’ Step: Cure.

2.4 Tasks (Azure's Pre-Built Steps)

A task is Azure's equivalent of a GitHub Action โ€” a pre-built, reusable step that does one thing. Azure provides hundreds of built-in tasks. The ones we use for iOS:

Task What It Does Azure Task Name
Install Apple Certificate Imports a .p12 certificate into the agent's Keychain InstallAppleCertificate@2
Install Apple Provisioning Profile Installs a .mobileprovision file on the agent InstallAppleProvisioningProfile@1
Xcode Build Builds, tests, or archives an Xcode project Xcode@5
CocoaPods Runs pod install CocoaPods@0
Publish Test Results Publishes test results to the pipeline's Tests tab PublishTestResults@2
Cache Caches and restores files between runs Cache@2
Download Secure File Downloads a file from Secure Files to the agent DownloadSecureFile@1
Copy Files Copies files (e.g., IPA to staging directory) CopyFiles@2
Publish Pipeline Artifact Saves files as downloadable pipeline artifacts PublishPipelineArtifact@1
Bash Runs a shell script Bash@3 or script: shorthand

You reference tasks in YAML like this:

- task: InstallAppleProvisioningProfile@1
  inputs:
    provisioningProfileLocation: 'secureFiles'
    provProfileSecureFile: 'MyApp_AppStore.mobileprovision'

2.5 Variables and Variable Groups

Variables are key-value pairs that parameterize your pipeline. They can be:

  • Inline โ€” defined directly in the YAML file (for non-sensitive values)
  • Pipeline variables โ€” set in the Azure DevOps web UI (for values you want to change without editing YAML)
  • Variable groups โ€” collections of variables that can be shared across multiple pipelines and linked to Azure Key Vault for enterprise-grade secrets management
  • Secret variables โ€” variables marked as secret (their values are masked in logs and cannot be read back from the UI)
variables:
  # Inline variables (visible in YAML, fine for non-sensitive values)
  SCHEME_NAME: 'MyApp-Staging'
  CONFIGURATION: 'Staging'
  APP_IDENTIFIER: 'com.yourcompany.myapp.staging'

  # Reference a variable group (secrets stored in Azure DevOps UI)
  - group: 'iOS-Signing-Secrets'
  # This group might contain:
  #   MATCH_PASSWORD (secret)
  #   ASC_KEY_ID (secret)
  #   ASC_ISSUER_ID (secret)
  #   SLACK_WEBHOOK_URL (secret)

2.6 Secure Files

Secure Files is an Azure DevOps feature specifically designed for files that are too sensitive to commit to your repository โ€” like signing certificates (.p12 files) and provisioning profiles (.mobileprovision files).

You upload these files to Azure DevOps once, and pipelines can download them during execution. The files are:

  • Encrypted at rest
  • Only accessible to pipelines you authorize
  • Never visible in logs
  • Deleted from the agent after the pipeline completes

This is Azure's primary mechanism for iOS code signing โ€” instead of using Fastlane Match (which stores credentials in a Git repo), Azure's approach is to store certificates and profiles as Secure Files and use built-in tasks to install them.

We cover this in detail in Chapter 7.

2.7 Service Connections

A service connection is a stored set of credentials for connecting to an external service. For iOS CI/CD, you will use:

  • Apple App Store service connection โ€” credentials for uploading to App Store Connect (TestFlight, App Store)
  • Git service connections โ€” if your source code is on GitHub or another provider outside Azure Repos

Service connections are created in the Azure DevOps project settings and referenced by name in your pipeline YAML. They are managed centrally, so if a password changes, you update it in one place.

2.8 Environments

An environment is a named deployment target (like "Staging" or "Production"). Environments enable:

  • Approval checks โ€” require a specific person to approve before the pipeline proceeds
  • Business-hours gates โ€” only deploy during business hours
  • Deployment history โ€” see every deployment to an environment, which pipeline triggered it, and which code was deployed
  • Exclusive locks โ€” prevent two pipelines from deploying to the same environment simultaneously

This is one of Azure Pipelines' strongest features for iOS โ€” you can set up a "Production" environment that requires your product manager's approval before the App Store submission proceeds.


Chapter 3: iOS Code Signing Deep Dive (Azure-Specific)

3.1 The Code Signing Problem

Every iOS app must be code signed before it can run on a device. Code signing involves:

  1. A signing certificate โ€” cryptographic proof of your identity as a developer
  2. A private key โ€” the secret half of the certificate's key pair
  3. A provisioning profile โ€” a file that ties together the certificate, app ID, and authorized devices

Your CI agent (the Mac running the pipeline) needs ALL of these. But CI agents start with a blank slate โ€” no certificates, no profiles, no keys.

3.2 Azure's Approach vs. Fastlane Match

There are two ways to handle code signing in Azure Pipelines:

Approach A: Azure Secure Files + Built-in Tasks (Azure-native)

  1. Export your .p12 certificate file and .mobileprovision profile from your Mac
  2. Upload them to Azure DevOps Secure Files
  3. In your pipeline, use InstallAppleCertificate@2 and InstallAppleProvisioningProfile@1 tasks to install them on the agent

Advantages: No external dependencies. Everything is managed within Azure DevOps. Simple to understand. Disadvantages: You must manually update Secure Files when certificates/profiles expire. No automatic renewal.

Approach B: Fastlane Match (same as GitHub Actions approach)

  1. Set up Match with a private Git repository for certificates (Chapter 5 of the companion guide)
  2. In your pipeline, run fastlane match to download and install credentials

Advantages: Automatic renewal, shared across all CI platforms and developer machines. Disadvantages: Requires a separate Git repo, introduces Ruby/Fastlane dependency.

This guide covers BOTH approaches. We start with Approach A (Azure-native) because it is simpler and uses Azure's built-in features. We then show Approach B for teams that already use Match or want cross-platform CI consistency.

3.3 Exporting Your Signing Credentials

Before you can upload anything to Azure, you need to export your credentials from your Mac.

Exporting the Certificate (.p12 File)

  1. Open Keychain Access on your Mac (search for it in Spotlight)
  2. In the left sidebar, click login (under Keychains) and My Certificates (under Category)
  3. Find your certificate:
    • For development builds: look for "Apple Development: Your Name"
    • For distribution builds: look for "Apple Distribution: Your Company"
  4. Click the triangle next to the certificate to reveal the private key underneath it
  5. Select BOTH the certificate AND the private key (hold Command and click both)
  6. Right-click โ†’ Export 2 items...
  7. Choose format: Personal Information Exchange (.p12)
  8. Save as distribution.p12 (or development.p12)
  9. Enter a strong password when prompted โ€” you will need this password in your pipeline. Save it securely.
  10. Enter your Mac login password to authorize the export

Critical: The .p12 file contains your PRIVATE KEY. Treat it like a password. If someone steals this file and its password, they can sign apps as if they were you.

Exporting the Provisioning Profile (.mobileprovision)

Option A โ€” Download from the Apple Developer Portal:

  1. Go to https://developer.apple.com/account/resources/profiles/list
  2. Find the profile you need (e.g., "MyApp AppStore" or "MyApp Development")
  3. Click on it โ†’ Download
  4. Save the .mobileprovision file

Option B โ€” Copy from your Mac:

# Provisioning profiles are stored here:
ls ~/Library/MobileDevice/Provisioning\ Profiles/

# To find which profile is which, decode them:
for f in ~/Library/MobileDevice/Provisioning\ Profiles/*.mobileprovision; do
  echo "=== $f ==="
  security cms -D -i "$f" | grep -A1 'Name\|AppIdentifier'
  echo
done

Copy the relevant .mobileprovision file.

You need profiles for each environment and each target (main app + extensions):

Profile Used For Certificate Type
MyApp_Development.mobileprovision Local development, debug builds Apple Development
MyApp_AppStore.mobileprovision TestFlight + App Store builds (Production) Apple Distribution
MyApp_Staging_AppStore.mobileprovision TestFlight builds (Staging) Apple Distribution
MyApp_Widget_AppStore.mobileprovision Widget extension (if applicable) Apple Distribution

Chapter 4: Setting Up Your Azure DevOps Project

4.1 Creating an Organization and Project

If you do not have an Azure DevOps organization yet:

  1. Go to https://dev.azure.com
  2. Sign in with a Microsoft account (or create one)
  3. Click New organization โ€” choose a name like your-company
  4. Create a New project โ€” name it after your app, e.g., MyApp-iOS
  5. Choose Git for version control and Agile for the work item process

Your project URL will be: https://dev.azure.com/your-company/MyApp-iOS

4.2 Connecting Your Repository

Azure Pipelines can build code from several sources:

Option A: Azure Repos (code lives in Azure DevOps)

If you want to host your code directly in Azure Repos:

  1. Navigate to Repos in the left sidebar
  2. Clone the empty repo: git clone https://dev.azure.com/your-company/MyApp-iOS/_git/MyApp-iOS
  3. Copy your Xcode project into this directory, commit, and push

Option B: GitHub Repository (code stays on GitHub)

If your code is on GitHub and you just want Azure Pipelines for CI/CD:

  1. Navigate to Project Settings (gear icon, bottom left) โ†’ Service connections
  2. Click New service connection โ†’ GitHub
  3. Authenticate with GitHub โ€” this creates a service connection that Azure uses to access your repo
  4. When creating a pipeline, select "GitHub" as the source and choose your repository

Option C: Bitbucket or other Git hosts

Similar to Option B โ€” create a service connection for your provider.

For this guide, we assume Option A (Azure Repos) for simplicity, but the pipeline YAML is identical regardless of where your code lives.

4.3 Uploading Secure Files

Now upload the signing credentials you exported in Chapter 3:

  1. Go to Pipelines โ†’ Library in the left sidebar
  2. Click the Secure files tab
  3. Click + Secure file
  4. Upload each file:
    • distribution.p12 โ€” your distribution certificate and private key
    • MyApp_AppStore.mobileprovision โ€” your App Store provisioning profile
    • MyApp_Staging_AppStore.mobileprovision โ€” your Staging provisioning profile
    • (Add any additional profiles for extensions)
  5. For each uploaded file, click on it and check "Authorize for use in all pipelines" (or authorize specific pipelines later)

Important: Remember the password you set when exporting the .p12 file. You will store it as a secret variable.

4.4 Creating a Variable Group for Secrets

  1. Go to Pipelines โ†’ Library
  2. Click + Variable group
  3. Name it iOS-Signing-Secrets
  4. Add the following variables:
Variable Name Value Is Secret?
P12_PASSWORD The password you set when exporting the .p12 file Yes (click the lock icon)
APPLE_ID Your Apple Developer account email No
TEAM_ID Your Apple Developer Team ID (e.g., A1B2C3D4E5) No
ASC_KEY_ID App Store Connect API Key ID Yes
ASC_ISSUER_ID App Store Connect API Issuer ID Yes
ASC_PRIVATE_KEY Contents of the .p8 API key file Yes
MATCH_PASSWORD Fastlane Match encryption password (if using Match) Yes
SLACK_WEBHOOK_URL Slack incoming webhook URL (optional) Yes
  1. Click Save

Now any pipeline that references the group iOS-Signing-Secrets can access these values as environment variables, but the secret values are never printed in logs.

4.5 Creating the App Store Connect API Key

This is the modern, recommended way to authenticate with Apple from CI. It avoids Apple ID passwords and 2FA prompts entirely.

  1. Go to https://appstoreconnect.apple.com
  2. Click Users and Access โ†’ Integrations โ†’ App Store Connect API
  3. Click Generate API Key
  4. Name: Azure CI Key
  5. Access: App Manager (or Admin)
  6. Click Generate
  7. Download the .p8 file immediately โ€” you can only download it once
  8. Note the Key ID and Issuer ID
  9. Store all three values in your variable group (see table above)

Chapter 5: Fastlane Setup for Azure Pipelines

5.1 Why Fastlane with Azure?

Even though Azure has built-in Xcode tasks, Fastlane provides significant advantages:

  • Consistent behavior โ€” the same Fastlane commands work locally, on Azure, on GitHub Actions, or any other CI platform. If you ever switch CI providers, your Fastfile does not change.
  • Better error messages โ€” Fastlane wraps xcodebuild with clear, actionable error messages instead of Xcode's often-cryptic output.
  • Rich ecosystem โ€” hundreds of plugins for everything from version bumping to Slack notifications.
  • TestFlight/App Store upload โ€” Fastlane's pilot and deliver are more flexible than Azure's built-in tasks.

You CAN use Azure's built-in Xcode@5 task instead of Fastlane. We show both approaches, but Fastlane is the recommended path for production teams.

5.2 Project Setup

Ensure your project has these files at the root:

Gemfile โ€” Pins Fastlane version:

# Gemfile
source "https://rubygems.org"

gem "fastlane", "~> 2.225"
gem "fastlane-plugin-versioning"

fastlane/Appfile โ€” App identity:

# fastlane/Appfile
apple_id(ENV["APPLE_ID"] || "developer@yourcompany.com")
team_id(ENV["TEAM_ID"] || "A1B2C3D4E5")
app_identifier("com.yourcompany.myapp")

fastlane/Fastfile โ€” Automation lanes (we build this in the following chapters):

# fastlane/Fastfile
# Lanes are defined in the pipeline chapters below.
# This is a placeholder โ€” the complete Fastfile is in Chapter 8.

Run locally to verify everything works:

bundle install
bundle exec fastlane lanes   # Should list available lanes

Commit Gemfile, Gemfile.lock, fastlane/Appfile, and fastlane/Fastfile to your repository.


Chapter 6: Your First Pipeline โ€” Build and Test on Every PR

6.1 The Pipeline YAML File

Create a file called azure-pipelines.yml at the root of your repository. This is where Azure Pipelines looks for your pipeline definition.

Here is a complete, production-ready pipeline that builds and tests your app on every pull request:

# azure-pipelines.yml
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# iOS CI Pipeline โ€” Build and Test on Every Pull Request
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

# โ”€โ”€ TRIGGER CONFIGURATION โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 'trigger' controls when the pipeline runs on pushes.
# 'pr' controls when the pipeline runs on pull requests.
#
# We DISABLE push triggers here because this pipeline
# is specifically for PR validation. Deployment pipelines
# are defined in separate YAML files (Chapters 8 and 9).

trigger: none   # Do not run on push โ€” only on PR

pr:
  branches:
    include:
      - main
      - develop
  paths:
    include:
      - '**/*.swift'
      - '**/*.xib'
      - '**/*.storyboard'
      - '**/*.xcodeproj/**'
      - '**/*.xcworkspace/**'
      - 'Podfile*'
      - 'Package.*'
      - 'fastlane/**'
      - 'azure-pipelines.yml'
    # When ONLY non-iOS files change (README, docs), the pipeline
    # does not run โ€” saving expensive macOS agent minutes.

# โ”€โ”€ VARIABLES โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
variables:
  WORKSPACE: 'MyApp.xcworkspace'
  SCHEME_TEST: 'MyApp-Dev'
  CONFIGURATION_TEST: 'Debug'
  SIMULATOR_DEVICE: 'iPhone 16 Pro'
  SIMULATOR_OS: '18.0'
  XCODE_VERSION: '16.0'

# โ”€โ”€ POOL (Agent Selection) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 'vmImage' selects a Microsoft-hosted macOS agent.
# Available images: macOS-13, macOS-14, macOS-15
# These come pre-installed with Xcode, Ruby, CocoaPods, etc.
pool:
  vmImage: 'macOS-15'

# โ”€โ”€ STEPS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
steps:
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # Step 1: Select Xcode Version
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # Microsoft-hosted agents have multiple Xcode versions installed.
  # We must explicitly select the one our project needs.
  # Without this, the agent uses its default (which might be different
  # from what you tested locally).
  - script: |
      sudo xcode-select -s /Applications/Xcode_$(XCODE_VERSION).app/Contents/Developer
      xcodebuild -version
    displayName: 'Select Xcode $(XCODE_VERSION)'
    # 'displayName' is the label shown in the pipeline UI.
    # Always make it descriptive โ€” you will thank yourself when debugging.

  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # Step 2: Cache Ruby Gems (Fastlane)
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # Caching saves 30-60 seconds on every build by reusing
  # previously downloaded gems instead of re-downloading them.
  - task: Cache@2
    inputs:
      key: 'gems | "$(Agent.OS)" | Gemfile.lock'
      path: 'vendor/bundle'
      restoreKeys: |
        gems | "$(Agent.OS)"
    displayName: 'Cache Ruby gems'

  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # Step 3: Install Ruby Gems (Fastlane)
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  - script: |
      bundle config path vendor/bundle
      bundle install --jobs 4 --retry 3
    displayName: 'Install Ruby gems (Fastlane)'

  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # Step 4: Cache CocoaPods
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  - task: Cache@2
    inputs:
      key: 'pods | "$(Agent.OS)" | Podfile.lock'
      path: 'Pods'
      restoreKeys: |
        pods | "$(Agent.OS)"
    displayName: 'Cache CocoaPods'

  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # Step 5: Install CocoaPods
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # If you use ONLY Swift Package Manager (no CocoaPods),
  # remove steps 4 and 5. Xcode resolves SPM automatically.
  - script: bundle exec pod install
    displayName: 'Install CocoaPods'

  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # Step 6: Run Tests via Fastlane
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  - script: bundle exec fastlane test
    displayName: 'Run tests'
    # If the test lane is not yet defined in your Fastfile,
    # you can use xcodebuild directly instead:
    # - script: |
    #     xcodebuild test \
    #       -workspace $(WORKSPACE) \
    #       -scheme $(SCHEME_TEST) \
    #       -configuration $(CONFIGURATION_TEST) \
    #       -destination 'platform=iOS Simulator,name=$(SIMULATOR_DEVICE),OS=$(SIMULATOR_OS)' \
    #       -resultBundlePath $(Build.ArtifactStagingDirectory)/TestResults.xcresult \
    #       -enableCodeCoverage YES \
    #       | xcpretty --report junit --output $(Build.ArtifactStagingDirectory)/test-results.xml
    #   displayName: 'Run tests (xcodebuild)'

  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # Step 7: Publish Test Results
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # This task reads the JUnit XML file and displays test results
  # in the "Tests" tab of the pipeline run in Azure DevOps.
  # You can see which tests passed/failed directly in the web UI.
  - task: PublishTestResults@2
    inputs:
      testResultsFormat: 'JUnit'
      testResultsFiles: '**/fastlane/test_output/report.junit'
      mergeTestResults: true
      testRunTitle: 'iOS Unit Tests'
    condition: always()
    # 'condition: always()' means this step runs even if tests failed.
    # We ALWAYS want to publish results โ€” especially on failure.
    displayName: 'Publish test results'

  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # Step 8: Publish Code Coverage (Optional)
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  - task: PublishCodeCoverageResults@2
    inputs:
      summaryFileLocation: '**/fastlane/test_output/coverage.xml'
    condition: always()
    displayName: 'Publish code coverage'
    continueOnError: true
    # 'continueOnError: true' means if coverage publishing fails
    # (e.g., file not found), the pipeline does not fail.

  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # Step 9: Upload Test Artifacts
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  - task: PublishPipelineArtifact@1
    inputs:
      targetPath: 'fastlane/test_output'
      artifact: 'test-results'
    condition: always()
    displayName: 'Upload test results artifact'

6.2 Understanding the YAML Structure

Let's highlight the Azure-specific patterns in this file:

Trigger vs. PR

Azure Pipelines has TWO trigger sections:

trigger:       # Controls runs on PUSH events (commits to a branch)
pr:            # Controls runs on PULL REQUEST events

This is different from GitHub Actions where both are under on:. In Azure, you can control them independently. For a test-only pipeline, you typically set trigger: none (disable push triggers) and configure pr: to run on PRs targeting specific branches.

Built-in Variables

Azure provides dozens of built-in variables that you can use in your YAML:

Variable Value Example
$(Build.ArtifactStagingDirectory) A pre-created directory for build outputs /Users/runner/work/1/a
$(Build.SourcesDirectory) Where your code is checked out /Users/runner/work/1/s
$(Build.BuildId) Unique ID for this pipeline run 12345
$(Build.BuildNumber) Human-readable build number 20260415.1
$(Agent.OS) The agent's operating system Darwin (macOS)
$(Build.SourceBranch) The branch being built refs/heads/develop
$(Build.Reason) Why the pipeline started PullRequest, Manual, IndividualCI

Conditions

Every step can have a condition that controls whether it runs:

condition: always()              # Always run, even if previous steps failed
condition: succeeded()           # Only run if all previous steps succeeded (default)
condition: failed()              # Only run if a previous step failed
condition: eq(variables['Build.Reason'], 'PullRequest')  # Only on PRs

6.3 Creating the Pipeline in Azure DevOps

Now let's connect this YAML file to Azure DevOps:

  1. Commit and push azure-pipelines.yml to your repository
  2. In Azure DevOps, navigate to Pipelines in the left sidebar
  3. Click New pipeline
  4. Select your repository source:
    • Azure Repos Git โ€” if your code is in Azure Repos
    • GitHub โ€” if your code is on GitHub (you will authorize Azure to access it)
  5. Select your repository
  6. Azure detects the azure-pipelines.yml file and shows you a preview
  7. Click Run to execute the pipeline for the first time

The pipeline will run, and you can watch each step in real-time in the Azure DevOps web UI.

6.4 Alternative: Using Azure's Built-in Xcode Task

If you prefer not to use Fastlane, Azure provides a built-in Xcode@5 task:

# Alternative to Fastlane for building and testing:
- task: Xcode@5
  inputs:
    actions: 'test'
    xcWorkspacePath: '$(WORKSPACE)'
    scheme: '$(SCHEME_TEST)'
    configuration: '$(CONFIGURATION_TEST)'
    sdk: 'iphonesimulator'
    destinationPlatformOption: 'iOS'
    destinationSimulators: '$(SIMULATOR_DEVICE)'
    publishJUnitResults: true
    xcodeVersion: 'specifyPath'
    xcodeDeveloperDir: '/Applications/Xcode_$(XCODE_VERSION).app/Contents/Developer'
  displayName: 'Build and test (Xcode task)'

The Xcode@5 task wraps xcodebuild and provides structured inputs. It can also automatically publish test results in JUnit format. However, Fastlane provides more flexibility, better error messages, and a consistent experience across CI platforms.


Chapter 7: Code Signing in Azure Pipelines

7.1 Approach A: Azure Secure Files (Azure-Native)

This is the simplest approach. You upload your .p12 and .mobileprovision files as Secure Files, then use Azure's built-in tasks to install them.

Step 1: Upload Files (Already Done in Chapter 4.3)

Verify your Secure Files are uploaded:

  • Go to Pipelines โ†’ Library โ†’ Secure files
  • You should see: distribution.p12, MyApp_AppStore.mobileprovision, etc.

Step 2: Add Signing Steps to Your Pipeline

# These steps go BEFORE the build/archive step in your deployment pipeline.

# โ”€โ”€ Install the signing certificate โ”€โ”€
- task: InstallAppleCertificate@2
  inputs:
    # The name of the .p12 file you uploaded to Secure Files
    certSecureFile: 'distribution.p12'
    # The password you set when exporting the .p12
    certPwd: '$(P12_PASSWORD)'
    # Where to install: 'tempKeychain' creates a temporary Keychain
    # that is automatically cleaned up after the pipeline.
    # 'defaultKeychain' uses the agent's login Keychain (not recommended for hosted agents).
    keychain: 'temp'
    # If true, deletes the Keychain after the pipeline completes.
    # Always true for security on hosted agents.
    deleteCert: true
  displayName: 'Install signing certificate'

# โ”€โ”€ Install the provisioning profile โ”€โ”€
- task: InstallAppleProvisioningProfile@1
  inputs:
    # Where the profile comes from
    provisioningProfileLocation: 'secureFiles'
    # The name of the .mobileprovision file you uploaded
    provProfileSecureFile: 'MyApp_AppStore.mobileprovision'
    # If true, removes the profile after the pipeline completes.
    removeProfile: true
  displayName: 'Install provisioning profile'

What these tasks output:

The InstallAppleCertificate@2 task sets these output variables:

  • signingIdentity โ€” the Common Name of the installed certificate (e.g., Apple Distribution: Your Company (TEAMID))
  • keychainPassword โ€” the password of the temporary Keychain it created

The InstallAppleProvisioningProfile@1 task sets:

  • provisioningProfileUuid โ€” the UUID of the installed profile

You reference these outputs in subsequent steps to tell Xcode which certificate and profile to use:

# Reference the outputs in the Xcode build step:
- task: Xcode@5
  inputs:
    actions: 'archive'
    xcWorkspacePath: '$(WORKSPACE)'
    scheme: 'MyApp-Production'
    configuration: 'Release'
    sdk: 'iphoneos'
    signingOption: 'manual'
    signingIdentity: '$(APPLE_CERTIFICATE_SIGNING_IDENTITY)'
    provisioningProfileUuid: '$(APPLE_PROV_PROFILE_PROFILE_UUID)'
    packageApp: true
    exportMethod: 'app-store'
    archivePath: '$(Build.ArtifactStagingDirectory)/archive'
    exportPath: '$(Build.ArtifactStagingDirectory)/export'
  displayName: 'Archive and export IPA'

Step 3: Multiple Profiles (Extensions)

If your app has extensions, install a profile for each:

- task: InstallAppleProvisioningProfile@1
  inputs:
    provisioningProfileLocation: 'secureFiles'
    provProfileSecureFile: 'MyApp_AppStore.mobileprovision'
  displayName: 'Install main app profile'

- task: InstallAppleProvisioningProfile@1
  inputs:
    provisioningProfileLocation: 'secureFiles'
    provProfileSecureFile: 'MyApp_Widget_AppStore.mobileprovision'
  displayName: 'Install widget profile'

7.2 Approach B: Fastlane Match

If you prefer Fastlane Match (recommended for teams that also develop locally and want consistent signing):

# Store MATCH_PASSWORD and MATCH_GIT_BASIC_AUTHORIZATION in your variable group.

- script: |
    bundle exec fastlane match appstore --readonly
  env:
    MATCH_PASSWORD: $(MATCH_PASSWORD)
    MATCH_GIT_BASIC_AUTHORIZATION: $(MATCH_GIT_BASIC_AUTHORIZATION)
  displayName: 'Install signing credentials (Match)'

Match clones the certificate repo, decrypts the credentials, and installs them โ€” same as on a developer's machine.

7.3 The ExportOptions.plist File

When archiving with xcodebuild directly (without Fastlane's build_app), you need an ExportOptions.plist that maps bundle IDs to provisioning profiles:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>app-store</string>

    <key>teamID</key>
    <string>A1B2C3D4E5</string>

    <key>signingStyle</key>
    <string>manual</string>

    <key>provisioningProfiles</key>
    <dict>
        <key>com.yourcompany.myapp</key>
        <string>MyApp AppStore Profile</string>
        <!-- Add entries for each extension: -->
        <!-- <key>com.yourcompany.myapp.widget</key>
        <string>MyApp Widget AppStore Profile</string> -->
    </dict>

    <key>uploadBitcode</key>
    <false/>
    <key>uploadSymbols</key>
    <true/>
</dict>
</plist>

Commit this file to your repository (e.g., at CI/ExportOptions-AppStore.plist).


Chapter 8: Staging Pipeline โ€” Automated TestFlight Deploys

8.1 The Complete Staging Pipeline

This pipeline runs when code is merged to develop and automatically deploys a Staging build to TestFlight.

Create a new file called azure-pipelines-staging.yml:

# azure-pipelines-staging.yml
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# Staging Deployment Pipeline
# Triggers: Push to develop branch
# Deploys: Staging build to TestFlight
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

trigger:
  branches:
    include:
      - develop
  paths:
    include:
      - '**/*.swift'
      - '**/*.xcodeproj/**'
      - '**/*.xcworkspace/**'
      - 'Podfile*'
      - 'fastlane/**'

pr: none   # This pipeline does NOT run on PRs

# โ”€โ”€ VARIABLES โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
variables:
  - group: 'iOS-Signing-Secrets'    # Import secrets from the variable group

  # Non-secret variables defined inline
  - name: WORKSPACE
    value: 'MyApp.xcworkspace'
  - name: SCHEME
    value: 'MyApp-Staging'
  - name: CONFIGURATION
    value: 'Staging'
  - name: APP_IDENTIFIER
    value: 'com.yourcompany.myapp.staging'
  - name: XCODE_VERSION
    value: '16.0'
  - name: EXPORT_METHOD
    value: 'app-store'

# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# STAGE 1: Build, Test, and Archive
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
stages:
  - stage: Build
    displayName: 'Build & Test'
    jobs:
      - job: BuildTestArchive
        displayName: 'Build, Test, and Archive IPA'
        pool:
          vmImage: 'macOS-15'
        timeoutInMinutes: 45

        steps:
          # โ”€โ”€ Environment Setup โ”€โ”€
          - script: |
              sudo xcode-select -s /Applications/Xcode_$(XCODE_VERSION).app/Contents/Developer
              xcodebuild -version
            displayName: 'Select Xcode $(XCODE_VERSION)'

          - task: Cache@2
            inputs:
              key: 'gems | "$(Agent.OS)" | Gemfile.lock'
              path: 'vendor/bundle'
            displayName: 'Cache Ruby gems'

          - script: |
              bundle config path vendor/bundle
              bundle install --jobs 4 --retry 3
            displayName: 'Install Fastlane'

          - task: Cache@2
            inputs:
              key: 'pods | "$(Agent.OS)" | Podfile.lock'
              path: 'Pods'
            displayName: 'Cache CocoaPods'

          - script: bundle exec pod install
            displayName: 'Install CocoaPods'

          # โ”€โ”€ Run Tests โ”€โ”€
          - script: bundle exec fastlane test
            displayName: 'Run unit tests'

          - task: PublishTestResults@2
            inputs:
              testResultsFormat: 'JUnit'
              testResultsFiles: '**/fastlane/test_output/report.junit'
              testRunTitle: 'Staging - Unit Tests'
            condition: always()
            displayName: 'Publish test results'

          # โ”€โ”€ Code Signing โ”€โ”€
          - task: InstallAppleCertificate@2
            inputs:
              certSecureFile: 'distribution.p12'
              certPwd: '$(P12_PASSWORD)'
              keychain: 'temp'
              deleteCert: true
            displayName: 'Install signing certificate'

          - task: InstallAppleProvisioningProfile@1
            inputs:
              provisioningProfileLocation: 'secureFiles'
              provProfileSecureFile: 'MyApp_Staging_AppStore.mobileprovision'
              removeProfile: true
            displayName: 'Install Staging provisioning profile'

          # โ”€โ”€ Build and Archive โ”€โ”€
          - script: |
              bundle exec fastlane staging
            env:
              APP_STORE_CONNECT_API_KEY_KEY_ID: $(ASC_KEY_ID)
              APP_STORE_CONNECT_API_KEY_ISSUER_ID: $(ASC_ISSUER_ID)
              APP_STORE_CONNECT_API_KEY_KEY: $(ASC_PRIVATE_KEY)
              SLACK_WEBHOOK_URL: $(SLACK_WEBHOOK_URL)
            displayName: 'Build and upload to TestFlight'

          # โ”€โ”€ Save Artifacts โ”€โ”€
          - task: CopyFiles@2
            inputs:
              sourceFolder: 'build'
              contents: '**/*.ipa'
              targetFolder: '$(Build.ArtifactStagingDirectory)'
            displayName: 'Copy IPA to artifact staging'

          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: '$(Build.ArtifactStagingDirectory)'
              artifact: 'staging-ipa'
            displayName: 'Publish IPA artifact'

8.2 The Fastfile for Staging Deployments

Here is the complete Fastfile that the pipeline above calls:

# fastlane/Fastfile

WORKSPACE    = "MyApp.xcworkspace"
APP_ID_PROD  = "com.yourcompany.myapp"
APP_ID_STG   = "com.yourcompany.myapp.staging"

before_all do
  # Set up CI keychain when running in Azure Pipelines
  # Azure sets the TF_BUILD environment variable to "True"
  setup_ci if ENV["TF_BUILD"] || is_ci
end

# โ”€โ”€ Test Lane โ”€โ”€
desc "Run unit tests"
lane :test do
  run_tests(
    workspace: WORKSPACE,
    scheme: "MyApp-Dev",
    devices: ["iPhone 16 Pro"],
    configuration: "Debug",
    code_coverage: true,
    output_directory: "./fastlane/test_output",
    output_types: "html,junit",
    clean: true,
    result_bundle: true
  )
end

# โ”€โ”€ Staging Lane โ”€โ”€
desc "Build and upload Staging to TestFlight"
lane :staging do
  setup_api_key

  increment_build_number(
    build_number: latest_testflight_build_number(
      app_identifier: APP_ID_STG
    ) + 1
  )

  build_app(
    workspace: WORKSPACE,
    scheme: "MyApp-Staging",
    configuration: "Staging",
    export_method: "app-store",
    output_directory: "./build",
    output_name: "MyApp-Staging.ipa",
    clean: true,
    include_bitcode: false,
    export_options: {
      provisioningProfiles: {
        APP_ID_STG => "match AppStore com.yourcompany.myapp.staging"
      }
    }
  )

  upload_to_testflight(
    app_identifier: APP_ID_STG,
    skip_waiting_for_build_processing: true,
    changelog: "Staging build ##{get_build_number} from Azure Pipelines\nBranch: #{git_branch}"
  )

  notify_slack(message: "โœ… Staging build ##{get_build_number} uploaded to TestFlight!")
end

# โ”€โ”€ Production Lane โ”€โ”€
desc "Build and upload Production to TestFlight"
lane :production do
  setup_api_key

  increment_build_number(
    build_number: latest_testflight_build_number(
      app_identifier: APP_ID_PROD
    ) + 1
  )

  build_app(
    workspace: WORKSPACE,
    scheme: "MyApp-Production",
    configuration: "Release",
    export_method: "app-store",
    output_directory: "./build",
    output_name: "MyApp-Production.ipa",
    clean: true,
    include_bitcode: false,
    export_options: {
      provisioningProfiles: {
        APP_ID_PROD => "match AppStore com.yourcompany.myapp"
      }
    }
  )

  upload_to_testflight(
    app_identifier: APP_ID_PROD,
    skip_waiting_for_build_processing: true,
    changelog: last_git_commit[:message]
  )

  notify_slack(message: "โœ… Production build ##{get_build_number} uploaded to TestFlight!")
end

# โ”€โ”€ Release Lane โ”€โ”€
desc "Submit latest TestFlight build for App Store review"
lane :release do
  setup_api_key

  deliver(
    app_identifier: APP_ID_PROD,
    submit_for_review: true,
    automatic_release: false,
    force: true,
    skip_screenshots: true,
    skip_metadata: false,
    submission_information: {
      add_id_info_uses_idfa: false
    }
  )

  notify_slack(message: "๐Ÿš€ App submitted for App Store review!")
end

# โ”€โ”€ Private Helper Lanes โ”€โ”€
private_lane :setup_api_key do
  app_store_connect_api_key(
    key_id: ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"],
    issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"],
    key_content: ENV["APP_STORE_CONNECT_API_KEY_KEY"],
    is_key_content_base64: false,
    in_house: false
  )
end

private_lane :notify_slack do |options|
  if ENV["SLACK_WEBHOOK_URL"]
    slack(
      message: options[:message],
      slack_url: ENV["SLACK_WEBHOOK_URL"],
      default_payloads: [:git_branch, :git_author, :build_number]
    )
  end
end

error do |lane, exception|
  if ENV["SLACK_WEBHOOK_URL"]
    slack(
      message: "โŒ Build failed in lane '#{lane}': #{exception.message}",
      slack_url: ENV["SLACK_WEBHOOK_URL"],
      success: false
    )
  end
end

8.3 The Azure-Native Approach (Without Fastlane)

If you prefer not to use Fastlane at all, here is how the archive and upload steps look using Azure's built-in tasks:

# Archive the app using Xcode task
- task: Xcode@5
  inputs:
    actions: 'archive'
    xcWorkspacePath: '$(WORKSPACE)'
    scheme: '$(SCHEME)'
    configuration: '$(CONFIGURATION)'
    sdk: 'iphoneos'
    signingOption: 'manual'
    signingIdentity: '$(APPLE_CERTIFICATE_SIGNING_IDENTITY)'
    provisioningProfileUuid: '$(APPLE_PROV_PROFILE_PROFILE_UUID)'
    packageApp: true
    exportMethod: 'app-store'
    exportPath: '$(Build.ArtifactStagingDirectory)/export'
    archivePath: '$(Build.ArtifactStagingDirectory)/archive'
  displayName: 'Archive and export IPA'

# Upload to App Store Connect / TestFlight
- task: AppStoreRelease@1
  inputs:
    serviceEndpoint: 'Apple App Store Connection'   # Service connection name
    appIdentifier: '$(APP_IDENTIFIER)'
    appType: 'iOS'
    ipaPath: '$(Build.ArtifactStagingDirectory)/export/**/*.ipa'
    releaseTrack: 'TestFlight'
    shouldSkipWaitingForProcessing: true
  displayName: 'Upload to TestFlight'

The AppStoreRelease@1 task requires an Apple App Store service connection in your project settings:

  1. Go to Project Settings โ†’ Service connections
  2. Click New service connection โ†’ Apple App Store
  3. Choose authentication method:
    • API Key (recommended) โ€” paste Key ID, Issuer ID, and the .p8 key content
    • Apple ID โ€” less recommended due to 2FA issues
  4. Name the connection (e.g., Apple App Store Connection)
  5. Reference it in the task by name

Chapter 9: Production Pipeline โ€” App Store Deployment

9.1 The Multi-Stage Production Pipeline

The production pipeline uses Azure's multi-stage feature to separate building from deploying, with an approval gate between them.

Create azure-pipelines-production.yml:

# azure-pipelines-production.yml
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# Production Deployment Pipeline
# Triggers: Tag matching v*.*.*
# Stage 1: Build + Test โ†’ produces IPA artifact
# Stage 2: Deploy to TestFlight (requires approval)
# Stage 3: Submit to App Store (requires approval)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

trigger:
  tags:
    include:
      - 'v*.*.*'

pr: none

variables:
  - group: 'iOS-Signing-Secrets'
  - name: WORKSPACE
    value: 'MyApp.xcworkspace'
  - name: XCODE_VERSION
    value: '16.0'

# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# STAGE 1: BUILD AND TEST
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
stages:
  - stage: Build
    displayName: 'Build & Test'
    jobs:
      - job: BuildAndTest
        pool:
          vmImage: 'macOS-15'
        timeoutInMinutes: 45
        steps:
          - script: sudo xcode-select -s /Applications/Xcode_$(XCODE_VERSION).app/Contents/Developer
            displayName: 'Select Xcode'

          - task: Cache@2
            inputs:
              key: 'gems | "$(Agent.OS)" | Gemfile.lock'
              path: 'vendor/bundle'
            displayName: 'Cache gems'

          - script: |
              bundle config path vendor/bundle
              bundle install --jobs 4 --retry 3
            displayName: 'Install Fastlane'

          - script: bundle exec pod install
            displayName: 'Install CocoaPods'

          # Tests
          - script: bundle exec fastlane test
            displayName: 'Run tests'

          - task: PublishTestResults@2
            inputs:
              testResultsFormat: 'JUnit'
              testResultsFiles: '**/fastlane/test_output/report.junit'
              testRunTitle: 'Production - Unit Tests'
            condition: always()
            displayName: 'Publish test results'

          # Code Signing
          - task: InstallAppleCertificate@2
            inputs:
              certSecureFile: 'distribution.p12'
              certPwd: '$(P12_PASSWORD)'
              keychain: 'temp'
            displayName: 'Install certificate'

          - task: InstallAppleProvisioningProfile@1
            inputs:
              provisioningProfileLocation: 'secureFiles'
              provProfileSecureFile: 'MyApp_AppStore.mobileprovision'
            displayName: 'Install profile'

          # Build
          - script: bundle exec fastlane production
            env:
              APP_STORE_CONNECT_API_KEY_KEY_ID: $(ASC_KEY_ID)
              APP_STORE_CONNECT_API_KEY_ISSUER_ID: $(ASC_ISSUER_ID)
              APP_STORE_CONNECT_API_KEY_KEY: $(ASC_PRIVATE_KEY)
              SLACK_WEBHOOK_URL: $(SLACK_WEBHOOK_URL)
            displayName: 'Build and upload to TestFlight'

          # Artifact
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: 'build'
              artifact: 'production-ipa'
            displayName: 'Publish IPA artifact'

  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
  # STAGE 2: SUBMIT TO APP STORE REVIEW
  # This stage requires manual approval before executing.
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
  - stage: SubmitReview
    displayName: 'Submit for App Store Review'
    dependsOn: Build
    condition: succeeded()
    jobs:
      - deployment: SubmitToAppStore
        displayName: 'Submit to App Store'
        pool:
          vmImage: 'macOS-15'
        environment: 'Production'
        # The 'environment' key links this job to an Azure Environment.
        # You configure approval checks on that environment (Chapter 11).
        strategy:
          runOnce:
            deploy:
              steps:
                - script: |
                    bundle config path vendor/bundle
                    bundle install --jobs 4 --retry 3
                  displayName: 'Install Fastlane'

                - script: bundle exec fastlane release
                  env:
                    APP_STORE_CONNECT_API_KEY_KEY_ID: $(ASC_KEY_ID)
                    APP_STORE_CONNECT_API_KEY_ISSUER_ID: $(ASC_ISSUER_ID)
                    APP_STORE_CONNECT_API_KEY_KEY: $(ASC_PRIVATE_KEY)
                    SLACK_WEBHOOK_URL: $(SLACK_WEBHOOK_URL)
                  displayName: 'Submit for App Store review'

9.2 How Multi-Stage Works

The pipeline above has two stages that run sequentially:

[Stage 1: Build & Test]
    โ”‚
    โ”‚  On success, passes artifacts to Stage 2
    โ”‚
    โ–ผ
[APPROVAL GATE]  โ† A human must click "Approve" in Azure DevOps
    โ”‚
    โ–ผ
[Stage 2: Submit for Review]
    โ”‚
    โ”‚  Runs Fastlane deliver to submit the build
    โ”‚
    โ–ผ
[App submitted to Apple for review]

When Stage 1 completes successfully, Azure DevOps pauses at the approval gate. The designated approver(s) receive an email and/or Teams notification. They can review the build artifacts, test results, and code changes before clicking "Approve" to proceed.

This is a significant advantage over GitHub Actions, which has simpler approval workflows. Azure's environment approvals support multiple approvers, business-hours restrictions, and timeout policies.


Chapter 10: Variable Groups, Secure Files, and Secrets Management

10.1 The Three Layers of Secrets

Azure Pipelines offers three mechanisms for managing sensitive data, each with different use cases:

Mechanism Best For Scope
Pipeline Variables (secret) One-off secrets used by a single pipeline Single pipeline
Variable Groups Secrets shared across multiple pipelines Project-wide (or org-wide)
Azure Key Vault Enterprise secrets with audit trails, rotation, and RBAC Organization-wide, integrated with Azure cloud

Pipeline Variables

Set directly on the pipeline in the Azure DevOps UI:

  1. Go to Pipelines โ†’ click on your pipeline โ†’ Edit
  2. Click Variables (top right)
  3. Add a variable, check "Keep this value secret"

These are the simplest but cannot be shared across pipelines.

Variable Groups

Created under Pipelines โ†’ Library and referenced in YAML:

variables:
  - group: 'iOS-Signing-Secrets'

One variable group can be linked to multiple pipelines. When you update a secret in the group, all pipelines automatically get the new value.

Azure Key Vault Integration

For enterprise teams, you can link a variable group to an Azure Key Vault:

  1. Create a Key Vault in Azure Portal
  2. Add secrets to the vault
  3. In Azure DevOps, create a variable group โ†’ select "Link secrets from an Azure key vault"
  4. Azure automatically syncs secrets from the vault into the variable group

This gives you:

  • Centralized secret management across all Azure services
  • Audit logs for every secret access
  • Automatic rotation policies
  • Fine-grained RBAC (who can read which secrets)

10.2 Secure Files Best Practices

File Naming Convention When to Update
distribution.p12 Include purpose in name When certificate expires (~1 year) or is revoked
development.p12 Include purpose in name When certificate expires
MyApp_AppStore.mobileprovision <AppName>_<Type>.mobileprovision When profile expires (~1 year), when adding capabilities, when adding devices
MyApp_Staging_AppStore.mobileprovision Include environment Same as above
ExportOptions-AppStore.plist Include export method When adding/removing extensions

Renewal process:

  1. Generate new certificate/profile in Apple Developer Portal (or re-run Fastlane Match)
  2. Export the new .p12 and .mobileprovision files
  3. Go to Pipelines โ†’ Library โ†’ Secure files
  4. Delete the old file
  5. Upload the new file with the SAME name
  6. Re-authorize it for your pipelines
  7. If the .p12 password changed, update the P12_PASSWORD variable

Chapter 11: Environments, Approvals, and Gated Releases

11.1 Creating Environments

Environments are deployment targets with governance rules.

  1. Go to Pipelines โ†’ Environments
  2. Click New environment
  3. Create two environments:
    • Staging โ€” no approval needed (automatic deploy on merge to develop)
    • Production โ€” requires approval before deploy

11.2 Adding Approval Checks

  1. Click on the Production environment
  2. Click the โ‹ฎ menu (top right) โ†’ Approvals and checks
  3. Click + โ†’ Approvals
  4. Add approvers (e.g., your tech lead, product manager)
  5. Configure:
    • Minimum approvals: 1 (or more for high-security teams)
    • Allow approvers to approve their own runs: Yes or No
    • Timeout: 72 hours (the pipeline waits this long for approval before failing)

Now, whenever a pipeline stage targets the Production environment, it will pause and wait for approval.

11.3 Business Hours Gate

You can also add a Business Hours check:

  1. On the Production environment โ†’ Approvals and checks โ†’ + โ†’ Business Hours
  2. Set allowed hours (e.g., Monday-Friday, 9:00 AM - 5:00 PM EST)

This prevents accidental Friday-night deploys. If a pipeline tries to deploy outside business hours, it waits until the next allowed window.

11.4 Exclusive Lock

Add an Exclusive Lock check to prevent two pipelines from deploying to the same environment simultaneously:

  1. Approvals and checks โ†’ + โ†’ Exclusive Lock

This ensures that if Pipeline A is deploying to Production, Pipeline B waits until A finishes.

11.5 Using Environments in YAML

Reference environments using the deployment job type:

- stage: Deploy
  jobs:
    - deployment: DeployToStaging
      environment: 'Staging'     # Links to the Staging environment
      strategy:
        runOnce:
          deploy:
            steps:
              - script: echo "Deploying to Staging..."

The key difference from a regular job is that a deployment job tracks deployment history and respects environment checks.


Chapter 12: Optimizing Build Performance

12.1 Caching Strategies

What Cache Key Path Time Saved
Ruby gems Gemfile.lock hash vendor/bundle 30-60s
CocoaPods Podfile.lock hash Pods 1-5 min
SPM packages Package.resolved hash ~/Library/Developer/Xcode/DerivedData/**/SourcePackages 1-3 min
DerivedData Swift file hashes ~/Library/Developer/Xcode/DerivedData 2-10 min

DerivedData Caching (Advanced)

- task: Cache@2
  inputs:
    key: 'deriveddata | "$(Agent.OS)" | **/*.swift | **/*.xcodeproj/**'
    path: '$(HOME)/Library/Developer/Xcode/DerivedData'
    restoreKeys: |
      deriveddata | "$(Agent.OS)"
  displayName: 'Cache DerivedData'

Warning: DerivedData caching can cause stale artifact issues. If you see unexplainable build failures, clear the cache by changing the key.

12.2 Self-Hosted macOS Agents

For teams building more than 50 times per month, self-hosted agents save significant money.

Setting Up a Self-Hosted Agent

Hardware: A Mac Mini (M2 or later) is ideal. Cost: ~$600-$800 one-time.

Steps:

  1. On the Mac, go to Project Settings โ†’ Agent pools โ†’ Default (or create a new pool) โ†’ Agents โ†’ New agent
  2. Download the macOS agent package
  3. Follow the setup instructions:
# Download and extract
cd ~/
mkdir azure-agent && cd azure-agent
tar xzf ~/Downloads/vsts-agent-osx-arm64-*.tar.gz

# Configure
./config.sh
# Enter: server URL (https://dev.azure.com/your-org)
# Enter: Personal Access Token (create one in Azure DevOps settings)
# Enter: agent pool name
# Enter: agent name (e.g., mac-mini-office)

# Install and start as a service (runs on boot)
./svc.sh install
./svc.sh start
  1. Install required tools on the Mac:
# Xcode (download from App Store or developer.apple.com)
sudo xcode-select -s /Applications/Xcode_16.0.app/Contents/Developer

# Ruby and Bundler
brew install ruby
gem install bundler

# CocoaPods
gem install cocoapods

# Fastlane (globally, as fallback)
gem install fastlane
  1. Reference it in your pipeline:
pool:
  name: 'Mac-Agents'   # Your custom agent pool name
  # Instead of:
  # vmImage: 'macOS-15'

Self-Hosted vs. Microsoft-Hosted Comparison

Factor Microsoft-Hosted Self-Hosted
Cost ~$0.08/minute $0 (after hardware purchase)
Startup time 1-2 minutes (provisioning) Instant (always running)
Clean state Yes (fresh VM every run) No (persistent โ€” faster builds, but potential cache issues)
Xcode updates Automatic (Microsoft manages) Manual (you install Xcode)
Maintenance Zero Medium (OS updates, disk space, agent updates)
Caching Remote cache (slower) Local filesystem (fast)

12.3 Parallelism

Run tests and builds in parallel across multiple agents:

strategy:
  parallel: 2   # Run on 2 agents simultaneously

Or use a matrix to test on multiple Xcode versions:

strategy:
  matrix:
    Xcode_15:
      XCODE_VERSION: '15.4'
    Xcode_16:
      XCODE_VERSION: '16.0'

Chapter 13: Templates and Reusable Pipeline Components

13.1 Why Templates?

If you have multiple iOS apps (or multiple pipelines for the same app), you will find yourself copying YAML between files. Templates let you define reusable components once and reference them everywhere.

Azure Pipelines supports three types of templates:

Template Type What It Defines Example Use
Step template A reusable set of steps "Install iOS dependencies" (Xcode selection, gems, pods)
Job template A reusable job "Build and test iOS app"
Stage template A reusable stage "Deploy to TestFlight"

13.2 Step Template Example

Create templates/setup-ios.yml:

# templates/setup-ios.yml
# Reusable step template for setting up an iOS build environment.

parameters:
  - name: xcodeVersion
    type: string
    default: '16.0'
  - name: installPods
    type: boolean
    default: true

steps:
  - script: |
      sudo xcode-select -s /Applications/Xcode_${{ parameters.xcodeVersion }}.app/Contents/Developer
      xcodebuild -version
    displayName: 'Select Xcode ${{ parameters.xcodeVersion }}'

  - task: Cache@2
    inputs:
      key: 'gems | "$(Agent.OS)" | Gemfile.lock'
      path: 'vendor/bundle'
    displayName: 'Cache Ruby gems'

  - script: |
      bundle config path vendor/bundle
      bundle install --jobs 4 --retry 3
    displayName: 'Install Fastlane'

  - ${{ if eq(parameters.installPods, true) }}:
    - task: Cache@2
      inputs:
        key: 'pods | "$(Agent.OS)" | Podfile.lock'
        path: 'Pods'
      displayName: 'Cache CocoaPods'

    - script: bundle exec pod install
      displayName: 'Install CocoaPods'

Use it in your pipeline:

steps:
  - template: templates/setup-ios.yml
    parameters:
      xcodeVersion: '16.0'
      installPods: true

  - script: bundle exec fastlane test
    displayName: 'Run tests'

13.3 Job Template Example

Create templates/ios-deploy.yml:

# templates/ios-deploy.yml
parameters:
  - name: lane
    type: string
  - name: environment
    type: string
  - name: certSecureFile
    type: string
    default: 'distribution.p12'
  - name: profileSecureFile
    type: string

jobs:
  - deployment: Deploy
    pool:
      vmImage: 'macOS-15'
    environment: ${{ parameters.environment }}
    strategy:
      runOnce:
        deploy:
          steps:
            - checkout: self

            - template: setup-ios.yml
              parameters:
                xcodeVersion: '16.0'

            - task: InstallAppleCertificate@2
              inputs:
                certSecureFile: ${{ parameters.certSecureFile }}
                certPwd: '$(P12_PASSWORD)'
                keychain: 'temp'
              displayName: 'Install certificate'

            - task: InstallAppleProvisioningProfile@1
              inputs:
                provisioningProfileLocation: 'secureFiles'
                provProfileSecureFile: ${{ parameters.profileSecureFile }}
              displayName: 'Install profile'

            - script: bundle exec fastlane ${{ parameters.lane }}
              env:
                APP_STORE_CONNECT_API_KEY_KEY_ID: $(ASC_KEY_ID)
                APP_STORE_CONNECT_API_KEY_ISSUER_ID: $(ASC_ISSUER_ID)
                APP_STORE_CONNECT_API_KEY_KEY: $(ASC_PRIVATE_KEY)
                SLACK_WEBHOOK_URL: $(SLACK_WEBHOOK_URL)
              displayName: 'Run Fastlane ${{ parameters.lane }}'

Use it:

stages:
  - stage: DeployStaging
    jobs:
      - template: templates/ios-deploy.yml
        parameters:
          lane: 'staging'
          environment: 'Staging'
          profileSecureFile: 'MyApp_Staging_AppStore.mobileprovision'

  - stage: DeployProduction
    jobs:
      - template: templates/ios-deploy.yml
        parameters:
          lane: 'production'
          environment: 'Production'
          profileSecureFile: 'MyApp_AppStore.mobileprovision'

This is extremely powerful โ€” you define the iOS deployment logic ONCE and reuse it with different parameters.


Chapter 14: Integrating Azure Boards with Pipelines

14.1 Automatic Work Item Linking

One of Azure DevOps's strongest features is the tight integration between Boards (work items) and Pipelines (builds/deployments). You can configure pipelines to automatically link builds to the work items they address.

When a developer includes a work item ID in their commit message:

git commit -m "Add login screen animations. Fixes #1234"

Azure DevOps automatically:

  1. Links the build to work item #1234
  2. Updates the work item's "Development" section with the commit, branch, and build
  3. Optionally moves the work item to a new state (e.g., from "Active" to "Resolved")

14.2 Deployment Tracking

When you use environments, Azure Boards can show:

  • Which work items were deployed to Staging
  • Which work items are in Production
  • Which work items are waiting for deployment

This gives product managers and stakeholders real-time visibility into what has shipped.

14.3 Release Notes Generation

You can automatically generate release notes from work items:

- script: |
    # Query Azure DevOps API for work items linked to this build
    # and generate a changelog
    echo "## What's New in Build $(Build.BuildNumber)" > release_notes.md
    echo "" >> release_notes.md
    echo "Built from branch: $(Build.SourceBranch)" >> release_notes.md
    echo "Commit: $(Build.SourceVersion)" >> release_notes.md
  displayName: 'Generate release notes'

Chapter 15: Troubleshooting Guide

15.1 Code Signing Errors

"No signing certificate found"

What it means: The certificate was not installed on the agent.

Checklist:

  1. Is the .p12 file uploaded in Secure Files?
  2. Is the InstallAppleCertificate@2 task running before the build task?
  3. Is the P12_PASSWORD variable correct?
  4. Is the Secure File authorized for this pipeline?

Debug: Add this step before the build to inspect the Keychain:

- script: security find-identity -v -p codesigning
  displayName: 'DEBUG: List signing identities'

"Provisioning profile doesn't match"

Checklist:

  1. Was the profile created with the same certificate you uploaded?
  2. Does the profile's App ID match your bundle identifier?
  3. Is the profile type correct? (App Store for TestFlight/Store, Development for debug)

Debug:

- script: |
    security cms -D -i ~/Library/MobileDevice/Provisioning\ Profiles/*.mobileprovision | grep -A2 'Name\|TeamIdentifier\|application-identifier'
  displayName: 'DEBUG: List installed profiles'

"The provisioning profile was not found"

The InstallAppleProvisioningProfile@1 task installs the profile to ~/Library/MobileDevice/Provisioning Profiles/ but the UUID-based filename may not match what Xcode expects. Use the task's output variable:

- task: InstallAppleProvisioningProfile@1
  name: installProfile    # Give the step a name to reference outputs
  inputs:
    provProfileSecureFile: 'MyApp_AppStore.mobileprovision'

# Reference the installed profile's UUID:
- script: echo "Profile UUID: $(installProfile.provisioningProfileUuid)"

15.2 Build Failures

"Command PhaseScriptExecution failed"

A Run Script build phase in Xcode failed. Common causes on CI:

  • Missing tool: A script references swiftlint but it is not installed on the agent
  • Permissions: The script does not have execute permission

Fix:

- script: |
    brew install swiftlint
    chmod +x Scripts/*.sh
  displayName: 'Install build tools'

"Dependency resolution failed" (SPM)

Swift Package Manager sometimes fails to resolve packages on CI.

Fix:

- script: |
    xcodebuild -resolvePackageDependencies \
      -workspace $(WORKSPACE) \
      -scheme $(SCHEME)
  displayName: 'Resolve SPM dependencies'

15.3 Pipeline Configuration Issues

"No hosted parallelism has been purchased or granted"

What it means: Your Azure DevOps organization does not have any parallel jobs allocated for Microsoft-hosted agents.

Fix: Go to Organization Settings โ†’ Parallel jobs โ†’ request or purchase Microsoft-hosted parallel jobs. For new organizations, you may need to request free tier access (Microsoft grants it after a short review).

Alternative: Set up a self-hosted Mac agent (Chapter 12.2) โ€” self-hosted parallelism is free.

Pipeline does not trigger on PR

Checklist:

  1. Is the pr: trigger section configured correctly in the YAML?
  2. Do the branch filters match? (include: [main, develop])
  3. Are the path filters correct? (If you have path filters, the pipeline only triggers when matching files change)
  4. Is the YAML file on the default branch? Azure reads the pipeline definition from the default branch for PR triggers.

Variables are empty

Checklist:

  1. Is the variable group linked to the pipeline? (Go to pipeline โ†’ Edit โ†’ Variables โ†’ Variable groups)
  2. Are secret variables being used correctly? Secret variables cannot be used in conditions or template expressions โ€” they are only available as environment variables in scripts.
# โœ… CORRECT: Secret variables as env vars in scripts
- script: echo "Signing..."
  env:
    MY_SECRET: $(mySecretVariable)

# โŒ WRONG: Secret variables in conditions
- script: echo "Signing..."
  condition: eq(variables['mySecretVariable'], 'some-value')
  # This will NOT work โ€” secret variables are empty in conditions

15.4 TestFlight Upload Failures

"Authentication failed"

Ensure you are using the App Store Connect API key (not Apple ID) and that the key has sufficient permissions (App Manager or Admin role).

"Missing compliance"

Add to Info.plist:

<key>ITSAppUsesNonExemptEncryption</key>
<false/>

Chapter 16: Complete Reference and Cheat Sheets

16.1 File Structure

MyApp/
โ”œโ”€โ”€ azure-pipelines.yml                  โ† PR test pipeline
โ”œโ”€โ”€ azure-pipelines-staging.yml          โ† Staging deploy pipeline
โ”œโ”€โ”€ azure-pipelines-production.yml       โ† Production deploy pipeline
โ”œโ”€โ”€ templates/
โ”‚   โ”œโ”€โ”€ setup-ios.yml                    โ† Reusable setup steps
โ”‚   โ””โ”€โ”€ ios-deploy.yml                   โ† Reusable deploy job
โ”œโ”€โ”€ CI/
โ”‚   โ””โ”€โ”€ ExportOptions-AppStore.plist     โ† Export options for xcodebuild
โ”œโ”€โ”€ fastlane/
โ”‚   โ”œโ”€โ”€ Appfile                          โ† App identity
โ”‚   โ”œโ”€โ”€ Fastfile                         โ† Automation lanes
โ”‚   โ”œโ”€โ”€ Matchfile                        โ† Code signing (if using Match)
โ”‚   โ”œโ”€โ”€ .env                             โ† Local secrets (git-ignored)
โ”‚   โ”œโ”€โ”€ metadata/                        โ† App Store metadata
โ”‚   โ””โ”€โ”€ test_output/                     โ† Test results (git-ignored)
โ”œโ”€โ”€ Gemfile                              โ† Ruby dependencies
โ”œโ”€โ”€ Gemfile.lock                         โ† Locked versions
โ””โ”€โ”€ .gitignore

16.2 Azure DevOps Setup Checklist

  • [ ] Azure DevOps Organization โ€” Created at dev.azure.com
  • [ ] Azure DevOps Project โ€” Created with Git version control
  • [ ] Repository โ€” Code pushed (Azure Repos or GitHub connected)
  • [ ] Secure Files โ€” .p12 certificate and .mobileprovision profiles uploaded
  • [ ] Variable Group โ€” iOS-Signing-Secrets created with all secrets
  • [ ] App Store Connect API Key โ€” Created, .p8 downloaded, values stored in variable group
  • [ ] Environments โ€” "Staging" and "Production" created
  • [ ] Production Approvals โ€” Approval check added to Production environment
  • [ ] Gemfile โ€” Created and committed
  • [ ] Fastlane โ€” Appfile and Fastfile configured
  • [ ] PR Pipeline โ€” azure-pipelines.yml created and pipeline configured
  • [ ] Staging Pipeline โ€” azure-pipelines-staging.yml created
  • [ ] Production Pipeline โ€” azure-pipelines-production.yml created
  • [ ] Test PR โ€” Verify tests run on a PR
  • [ ] Test Staging โ€” Merge to develop, verify TestFlight upload
  • [ ] Test Production โ€” Push tag, verify TestFlight upload
  • [ ] Slack โ€” Notifications configured and tested

16.3 YAML Quick Reference

# โ”€โ”€ Triggers โ”€โ”€
trigger:
  branches:
    include: [main, develop]
    exclude: [feature/experimental]
  tags:
    include: ['v*.*.*']
  paths:
    include: ['**/*.swift']
    exclude: ['docs/**']

pr:
  branches:
    include: [main, develop]

# โ”€โ”€ Variables โ”€โ”€
variables:
  MY_VAR: 'value'                     # Inline
  - group: 'My-Variable-Group'        # Variable group
  - name: MY_VAR
    value: 'value'                     # Named syntax

# โ”€โ”€ Pool โ”€โ”€
pool:
  vmImage: 'macOS-15'                  # Microsoft-hosted
  # OR:
  name: 'My-Mac-Pool'                 # Self-hosted

# โ”€โ”€ Stages/Jobs/Steps โ”€โ”€
stages:
  - stage: Build
    jobs:
      - job: MyJob
        steps:
          - script: echo "Hello"       # Inline script
            displayName: 'Say hello'

          - task: TaskName@Version     # Built-in task
            inputs:
              key: 'value'

          - template: path/to/template.yml  # Template reference
            parameters:
              param1: 'value'

# โ”€โ”€ Conditions โ”€โ”€
condition: always()
condition: succeeded()
condition: failed()
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))

# โ”€โ”€ Deployment Job โ”€โ”€
- deployment: DeployJob
  environment: 'Production'
  strategy:
    runOnce:
      deploy:
        steps:
          - script: echo "Deploying"

16.4 Key Azure CLI Commands

# โ”€โ”€ Pipeline Management โ”€โ”€
az pipelines run --name 'MyPipeline' --branch develop    # Trigger manually
az pipelines list --org https://dev.azure.com/your-org   # List pipelines
az pipelines show --name 'MyPipeline'                     # Pipeline details

# โ”€โ”€ Variable Groups โ”€โ”€
az pipelines variable-group list                          # List groups
az pipelines variable-group variable update \
  --group-id 1 --name MY_VAR --value "new-value"         # Update variable

# โ”€โ”€ Agent Management โ”€โ”€
az pipelines agent list --pool-id 1                       # List agents in pool

16.5 The Complete Mental Model

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                    YOUR CODE (Azure Repos / GitHub)    โ”‚
โ”‚                                                        โ”‚
โ”‚  PR to develop   โ”€โ”€โ–บ azure-pipelines.yml: TEST ONLY   โ”‚
โ”‚  Merge to developโ”€โ”€โ–บ azure-pipelines-staging.yml:      โ”‚
โ”‚                       TEST + STAGING TESTFLIGHT        โ”‚
โ”‚  Tag v1.2.3      โ”€โ”€โ–บ azure-pipelines-production.yml:   โ”‚
โ”‚                       TEST + PROD TESTFLIGHT           โ”‚
โ”‚                       โ†’ [APPROVAL] โ†’ APP STORE SUBMIT  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚
               โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚            AZURE PIPELINES (macOS Agent)               โ”‚
โ”‚                                                        โ”‚
โ”‚  1. Select Xcode version                               โ”‚
โ”‚  2. Install deps (Fastlane, CocoaPods/SPM)             โ”‚
โ”‚  3. Code signing (Secure Files or Fastlane Match)      โ”‚
โ”‚  4. Build (xcodebuild via Fastlane)                    โ”‚
โ”‚  5. Test (xcodebuild test via Fastlane)                โ”‚
โ”‚  6. Archive + Export IPA                               โ”‚
โ”‚  7. Upload to TestFlight                               โ”‚
โ”‚  8. [Approval Gate] โ†’ Submit to App Store              โ”‚
โ”‚  9. Notify Slack / Teams                               โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚
               โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚              APP STORE CONNECT                         โ”‚
โ”‚                                                        โ”‚
โ”‚  TestFlight โ”€โ”€โ–บ QA testers install and test             โ”‚
โ”‚  App Store  โ”€โ”€โ–บ Apple reviews โ”€โ”€โ–บ Public release        โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Final Words

Azure Pipelines is a production-grade CI/CD platform that offers several unique advantages for iOS teams: enterprise-grade approval workflows, deep integration with Azure Boards for end-to-end traceability, free unlimited self-hosted agents, powerful YAML templates for reuse, and Secure Files for straightforward certificate management.

The setup is more involved than Xcode Cloud but gives you far more control. The YAML syntax has a learning curve compared to GitHub Actions, but the multi-stage pipeline support and environment approval system are more mature.

The core workflow is the same regardless of which CI platform you use: push code, build, test, sign, archive, deploy. The differences are in HOW you configure each step. If you have already read the companion GitHub Actions guide, you will notice that the Fastfile is identical โ€” only the pipeline YAML wrapper changes. This is intentional and one of Fastlane's greatest strengths.

One git push. Azure takes it from there.


This guide is part of the iOS CI/CD series. See also: "iOS CI/CD with GitHub Actions" and "iOS Xcode: Configurations, Schemes & Targets."