iOS CI/CD with Azure DevOps: The Complete Production Guide
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
- Chapter 1: Azure DevOps for iOS โ Why and When
- Chapter 2: Azure DevOps Concepts Explained from Scratch
- Chapter 3: iOS Code Signing Deep Dive (Azure-Specific)
- Chapter 4: Setting Up Your Azure DevOps Project
- Chapter 5: Fastlane Setup for Azure Pipelines
- Chapter 6: Your First Pipeline โ Build and Test on Every PR
- Chapter 7: Code Signing in Azure Pipelines
- Chapter 8: Staging Pipeline โ Automated TestFlight Deploys
- Chapter 9: Production Pipeline โ App Store Deployment
- Chapter 10: Variable Groups, Secure Files, and Secrets Management
- Chapter 11: Environments, Approvals, and Gated Releases
- Chapter 12: Optimizing Build Performance
- Chapter 13: Templates and Reusable Pipeline Components
- Chapter 14: Integrating Azure Boards with Pipelines
- Chapter 15: Troubleshooting Guide
- 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:
- A signing certificate โ cryptographic proof of your identity as a developer
- A private key โ the secret half of the certificate's key pair
- 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)
- Export your
.p12certificate file and.mobileprovisionprofile from your Mac - Upload them to Azure DevOps Secure Files
- In your pipeline, use
InstallAppleCertificate@2andInstallAppleProvisioningProfile@1tasks 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)
- Set up Match with a private Git repository for certificates (Chapter 5 of the companion guide)
- In your pipeline, run
fastlane matchto 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)
- Open Keychain Access on your Mac (search for it in Spotlight)
- In the left sidebar, click login (under Keychains) and My Certificates (under Category)
- Find your certificate:
- For development builds: look for "Apple Development: Your Name"
- For distribution builds: look for "Apple Distribution: Your Company"
- Click the triangle next to the certificate to reveal the private key underneath it
- Select BOTH the certificate AND the private key (hold Command and click both)
- Right-click โ Export 2 items...
- Choose format: Personal Information Exchange (.p12)
- Save as
distribution.p12(ordevelopment.p12) - Enter a strong password when prompted โ you will need this password in your pipeline. Save it securely.
- 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:
- Go to https://developer.apple.com/account/resources/profiles/list
- Find the profile you need (e.g., "MyApp AppStore" or "MyApp Development")
- Click on it โ Download
- Save the
.mobileprovisionfile
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:
- Go to https://dev.azure.com
- Sign in with a Microsoft account (or create one)
- Click New organization โ choose a name like
your-company - Create a New project โ name it after your app, e.g.,
MyApp-iOS - 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:
- Navigate to Repos in the left sidebar
- Clone the empty repo:
git clone https://dev.azure.com/your-company/MyApp-iOS/_git/MyApp-iOS - 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:
- Navigate to Project Settings (gear icon, bottom left) โ Service connections
- Click New service connection โ GitHub
- Authenticate with GitHub โ this creates a service connection that Azure uses to access your repo
- 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:
- Go to Pipelines โ Library in the left sidebar
- Click the Secure files tab
- Click + Secure file
- Upload each file:
distribution.p12โ your distribution certificate and private keyMyApp_AppStore.mobileprovisionโ your App Store provisioning profileMyApp_Staging_AppStore.mobileprovisionโ your Staging provisioning profile- (Add any additional profiles for extensions)
- 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
- Go to Pipelines โ Library
- Click + Variable group
- Name it
iOS-Signing-Secrets - 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 |
- 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.
- Go to https://appstoreconnect.apple.com
- Click Users and Access โ Integrations โ App Store Connect API
- Click Generate API Key
- Name:
Azure CI Key - Access: App Manager (or Admin)
- Click Generate
- Download the
.p8file immediately โ you can only download it once - Note the Key ID and Issuer ID
- 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
xcodebuildwith 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
pilotanddeliverare 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:
- Commit and push
azure-pipelines.ymlto your repository - In Azure DevOps, navigate to Pipelines in the left sidebar
- Click New pipeline
- 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)
- Select your repository
- Azure detects the
azure-pipelines.ymlfile and shows you a preview - 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:
- Go to Project Settings โ Service connections
- Click New service connection โ Apple App Store
- Choose authentication method:
- API Key (recommended) โ paste Key ID, Issuer ID, and the
.p8key content - Apple ID โ less recommended due to 2FA issues
- API Key (recommended) โ paste Key ID, Issuer ID, and the
- Name the connection (e.g.,
Apple App Store Connection) - 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:
- Go to Pipelines โ click on your pipeline โ Edit
- Click Variables (top right)
- 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:
- Create a Key Vault in Azure Portal
- Add secrets to the vault
- In Azure DevOps, create a variable group โ select "Link secrets from an Azure key vault"
- 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:
- Generate new certificate/profile in Apple Developer Portal (or re-run Fastlane Match)
- Export the new
.p12and.mobileprovisionfiles - Go to Pipelines โ Library โ Secure files
- Delete the old file
- Upload the new file with the SAME name
- Re-authorize it for your pipelines
- If the
.p12password changed, update theP12_PASSWORDvariable
Chapter 11: Environments, Approvals, and Gated Releases
11.1 Creating Environments
Environments are deployment targets with governance rules.
- Go to Pipelines โ Environments
- Click New environment
- Create two environments:
- Staging โ no approval needed (automatic deploy on merge to develop)
- Production โ requires approval before deploy
11.2 Adding Approval Checks
- Click on the Production environment
- Click the โฎ menu (top right) โ Approvals and checks
- Click + โ Approvals
- Add approvers (e.g., your tech lead, product manager)
- 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:
- On the Production environment โ Approvals and checks โ + โ Business Hours
- 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:
- 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:
- On the Mac, go to Project Settings โ Agent pools โ Default (or create a new pool) โ Agents โ New agent
- Download the macOS agent package
- 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
- 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
- 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:
- Links the build to work item #1234
- Updates the work item's "Development" section with the commit, branch, and build
- 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:
- Is the
.p12file uploaded in Secure Files? - Is the
InstallAppleCertificate@2task running before the build task? - Is the
P12_PASSWORDvariable correct? - 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:
- Was the profile created with the same certificate you uploaded?
- Does the profile's App ID match your bundle identifier?
- Is the profile type correct? (
App Storefor TestFlight/Store,Developmentfor 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
swiftlintbut 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:
- Is the
pr:trigger section configured correctly in the YAML? - Do the branch filters match? (
include: [main, develop]) - Are the path filters correct? (If you have path filters, the pipeline only triggers when matching files change)
- Is the YAML file on the default branch? Azure reads the pipeline definition from the default branch for PR triggers.
Variables are empty
Checklist:
- Is the variable group linked to the pipeline? (Go to pipeline โ Edit โ Variables โ Variable groups)
- 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 โ
.p12certificate and.mobileprovisionprofiles uploaded - [ ] Variable Group โ
iOS-Signing-Secretscreated with all secrets - [ ] App Store Connect API Key โ Created,
.p8downloaded, 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.ymlcreated and pipeline configured - [ ] Staging Pipeline โ
azure-pipelines-staging.ymlcreated - [ ] Production Pipeline โ
azure-pipelines-production.ymlcreated - [ ] 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."