Domain 3.3 · part of 50–55%

Design and implement pipelines

The single densest sub-objective in the exam: which automation platform, what compute runs the work, what makes it start, how the YAML is structured, how work runs in parallel, and how you avoid writing the same pipeline nine times.

Selecting a deployment automation solution

flowchart TD A{Where does the source live?} -->|TFVC| P[Azure Pipelines
only option] A -->|Azure Repos / Bitbucket| P A -->|GitHub| B{What matters most?} B -->|GitHub-native ecosystem
Dependabot · GHAS · Projects| G[GitHub Actions] B -->|Rich release management
stages · gates · deployment groups| H[Azure Pipelines
via GitHub App] B -->|Open source project| G
Source control location narrows the choice; the rest is about which release-management model you need.

Caveat — the three decisive facts

1) GitHub Actions cannot build from TFVC — a legacy TFVC repo forces Azure Pipelines. 2) Azure Pipelines can build from GitHub, but not the reverse. 3) Only Azure Pipelines has stages, classic release gates and deployment groups. Everything else is largely preference.

Agent and runner infrastructure

Microsoft-hosted agents / GitHub-hosted runners

HostedSelf-hosted
MaintenanceNone — fresh VM per jobYou patch, update and monitor it
State between runsNone — clean image every timePersists — caches, Docker layers, tool installs
Private network accessNo (public internet only)Yes — the decisive advantage
Custom software / licensed toolsOnly what's in the imageAnything you install
Cost modelPer-minute / parallel job purchaseInfrastructure + management effort
Best forShort, standard, bursty buildsLong or frequent builds, private resources, compliance
Common image labelNotes
ubuntu-latestCheapest and fastest; required for Linux containers and service containers
windows-latestWindows Server with VS Build Tools; ~2× the minute cost on GitHub
macos-latestXcode; ~10× the minute cost on GitHub — use only for Apple builds

Caveat — when the answer is "self-hosted"

Any scenario mentioning on-premises resources, a private VNet, a private SQL Server, a licensed proprietary tool, data that may not leave the network, or "the build takes 40 minutes mostly downloading dependencies" resolves to a self-hosted agent/runner. Hosted agents cannot reach private endpoints and start from a clean image every time.

Deploying self-hosted agents and runners

bash Azure Pipelines self-hosted agent
mkdir myagent && cd myagent
tar zxvf ~/Downloads/vsts-agent-linux-x64-*.tar.gz

# Configure. Prefer a service principal / managed identity over a PAT where supported.
./config.sh --unattended \
  --url https://dev.azure.com/MyOrg \
  --auth pat --token "$AZP_TOKEN" \
  --pool MyLinuxPool \
  --agent "$(hostname)" \
  --acceptTeeEula

sudo ./svc.sh install && sudo ./svc.sh start   # run as a service
yaml targeting agents by capability
pool:
  name: MyLinuxPool          # self-hosted pool
  demands:
    - Agent.OS -equals Linux
    - HasDocker               # custom capability set on the agent
    - java -equals 21
Scaling approachPlatformUse for
Azure VM Scale Set agentsAzure PipelinesElastic self-hosted pool that Azure DevOps scales for you
Container jobsBothRun steps inside a specified image for a reproducible toolchain
Actions Runner Controller (ARC)GitHubKubernetes-based ephemeral runners that autoscale to zero
Larger runnersGitHubMore vCPU/RAM, and static IP ranges for firewall allow-listing
Runner groupsGitHubRestrict which repos/workflows may use sensitive runners

Never use self-hosted runners on public repositories

A fork can open a PR that executes arbitrary code on your runner, and self-hosted runners persist state between jobs. GitHub explicitly recommends self-hosted runners only for private repositories; where they must be used, make them ephemeral (destroyed after each job) and place them in a restricted runner group.

Parallel jobs

Azure DevOps bills concurrency as parallel jobs: private projects get 1 free Microsoft-hosted parallel job with limited monthly minutes (self-hosted gets 1 free), and public projects get 10 free. Buying more parallelism — not a faster agent — is the fix for "builds queue for 20 minutes". See 3.6.

Integrating GitHub repositories with Azure Pipelines

Connection typeHow it authenticatesRecommended?
Azure Pipelines GitHub AppApp installation with scoped repo permissions; posts checks nativelyYes — least privilege, no user token
OAuthActs as the authorising user's accountFallback; breaks if that user leaves
Personal access tokenA stored PATLast resort; rotation burden
azure-pipelines.yml PR validation for a GitHub repo
pr:
  branches:
    include: [main, 'release/*']
  paths:
    exclude: ['docs/*', '**/*.md']
  drafts: false          # don't burn agent minutes on draft PRs

Caveat — GitHub PR triggers ignore the YAML branch filter for the target

For GitHub repos, Azure Pipelines PR triggers are driven by the GitHub App/branch protection, and comment triggers (/azp run) can be required for PRs from forks. Also, secrets are not exposed to fork PR builds by default — a scenario about "a fork PR build fails because it can't read a secret" is working as designed, and the answer is to not loosen it.

Pipeline trigger rules

azure-pipelines.yml triggers
# CI trigger
trigger:
  batch: true            # queue changes while a run is in progress
  branches:
    include: [main, 'release/*']
    exclude: ['experimental/*']
  paths:
    include: ['src/*']
    exclude: ['docs/*', '**/*.md']
  tags:
    include: ['v*']

# trigger: none   → disable CI entirely

pr:
  branches: { include: [main] }
  autoCancel: true       # cancel older runs on a new push

schedules:
  - cron: '0 2 * * 1-5'  # 02:00 UTC, Mon–Fri
    displayName: Nightly
    branches: { include: [main] }
    always: true         # run even with no code changes

# Pipeline-completion trigger
resources:
  pipelines:
    - pipeline: buildArtifacts
      source: 'MyApp-CI'
      trigger:
        branches: { include: [main] }
GitHub Actions events
on:
  push:
    branches: [main, 'release/**']
    paths-ignore: ['docs/**', '**/*.md']
    tags: ['v*']

  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened, ready_for_review]

  schedule:
    - cron: '0 2 * * 1-5'      # always UTC

  workflow_dispatch:            # manual, with inputs
    inputs:
      environment:
        description: Target environment
        required: true
        default: staging
        type: choice
        options: [staging, production]

  workflow_call:                # reusable workflow
  repository_dispatch:          # external API trigger
    types: [deploy-prod]

  workflow_run:                 # after another workflow finishes
    workflows: ['CI']
    types: [completed]
RequirementAzure PipelinesGitHub Actions
Manual run with parametersparameters: + queue-time UIworkflow_dispatch: with inputs:
Trigger from an external systemREST API / webhook resourcerepository_dispatch
Chain after another pipelineresources: pipelines: triggerworkflow_run
Skip docs-only changespaths: exclude:paths-ignore:
Run nightly regardless of changesschedules: + always: trueschedule: (always runs)
Cancel superseded PR runspr: autoCancel: trueconcurrency: cancel-in-progress: true

Caveats — three trigger traps

1) cron is always UTC in both platforms — a "9 am local" requirement needs the UTC offset, and daylight saving will shift it. 2) Azure Pipelines schedules: without always: true only runs if the source changed since the last scheduled run. 3) GitHub disables scheduled workflows in repositories with no activity for 60 days.

Developing pipelines with YAML

The object hierarchy

flowchart TB subgraph AP["Azure Pipelines"] P1[Pipeline] --> S1[Stage] S1 --> J1["Job / deployment job"] J1 --> T1[Step: task or script] end subgraph GA["GitHub Actions"] W1[Workflow] --> JJ["Job"] JJ --> TT[Step: uses or run] end
Azure Pipelines has an extra level — the stage. Jobs in both platforms run on separate agents/runners by default.
azure-pipelines.yml a complete multi-stage pipeline
name: $(Date:yyyyMMdd)$(Rev:.r)

trigger:
  branches: { include: [main] }

variables:
  - name: buildConfiguration
    value: Release
  - group: 'Shared-Settings'          # variable group from Library
  - template: vars/common.yml         # variables from a template file

stages:
  - stage: Build
    displayName: Build and test
    jobs:
      - job: BuildJob
        pool: { vmImage: ubuntu-latest }
        steps:
          - checkout: self
            fetchDepth: 0             # full history: needed by GitVersion / SonarQube

          - template: templates/build-steps.yml
            parameters:
              buildConfiguration: $(buildConfiguration)

          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: '$(Build.ArtifactStagingDirectory)'
              artifact: drop

  - stage: DeployStaging
    dependsOn: Build
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: DeployStaging     # a DEPLOYMENT job, not a plain job
        environment: staging
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: drop
                - script: ./deploy.sh $(Pipeline.Workspace)/drop

  - stage: DeployProd
    dependsOn: DeployStaging
    jobs:
      - deployment: DeployProd
        environment: production        # approvals/checks configured on the environment
        strategy:
          runOnce:
            deploy:
              steps:
                - script: ./deploy.sh

Caveat — deployment: vs job:

A deployment job (- deployment:) is what binds to an environment:, records deployment history, exposes strategies (runOnce, rolling, canary) with lifecycle hooks, and automatically downloads the current pipeline's artifacts. A plain - job: does none of that. If the requirement mentions approvals, deployment history or a rollout strategy, it must be a deployment job.

Variables, parameters and expressions

SyntaxEvaluatedUse for
${{ }} — compile timeWhen the YAML is parsed, before the run startsparameters, conditional structure (adding/removing steps), template logic
$[ ] — runtime expressionAt the start of the stage/jobcounter(), dependencies.* outputs, conditions on prior results
$( ) — macroJust before each step executesEveryday variable substitution inside step inputs

Caveat — parameters vs variables

Parameters are typed, are resolved at compile time, and can change the shape of the pipeline (add a stage, drop a step). Variables are always strings and are resolved at runtime — they can change values but not structure. "Let the user pick which environments get a stage" → parameters. "Pass a connection string to a task" → variable.

yaml passing an output between jobs
jobs:
  - job: Setup
    steps:
      - script: echo "##vso[task.setvariable variable=version;isOutput=true]1.4.2"
        name: setVar                 # the step NAME is required to reference the output

  - job: Use
    dependsOn: Setup
    variables:
      ver: $[ dependencies.Setup.outputs['setVar.version'] ]
    steps:
      - script: echo "Building $(ver)"
GitHub Actions the same idea
jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.v.outputs.value }}
    steps:
      - id: v
        run: echo "value=1.4.2" >> "$GITHUB_OUTPUT"

  use:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building ${{ needs.setup.outputs.version }}"

Job execution order, parallelism and multi-stage

flowchart LR T[Trigger] --> B[Build] B --> L[Test · Linux] B --> W[Test · Windows] B --> M[Test · macOS] L --> A[Publish results] W --> A M --> A A --> D[Deploy]
Fan-out / fan-in: three test jobs run concurrently, then one aggregation job depends on all three.
azure-pipelines.yml dependsOn + condition
jobs:
  - job: TestLinux
    steps: [ { script: dotnet test } ]
  - job: TestWindows
    steps: [ { script: dotnet test } ]

  - job: Publish
    dependsOn: [TestLinux, TestWindows]
    condition: always()      # run even if a test job failed
    steps:
      - script: echo "aggregate"

# dependsOn: []  → start immediately, ignore implicit ordering
GitHub Actions needs + if
jobs:
  test-linux:
    runs-on: ubuntu-latest
    steps: [ { run: npm test } ]
  test-windows:
    runs-on: windows-latest
    steps: [ { run: npm test } ]

  publish:
    needs: [test-linux, test-windows]
    if: always()
    runs-on: ubuntu-latest
    steps: [ { run: echo aggregate } ]

Caveat — default ordering differs by level

In Azure Pipelines, stages run sequentially by default (each implicitly depends on the previous), while jobs within a stage run in parallel by default. To make stages parallel, set dependsOn: []. In GitHub Actions, all jobs run in parallel unless linked with needs:. Getting this backwards is a common mistake.

Conditions cheat sheet

Azure Pipelines: succeeded() (default), succeededOrFailed(), failed(), always(), canceled(), and expressions like and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')). GitHub Actions: success() (default), failure(), always(), cancelled() inside if:. Note always() also runs on cancellation — use succeededOrFailed()/!cancelled() when you do not want that.

Reusable pipeline elements

MechanismPlatformReuses
Steps templateAzure PipelinesA sequence of steps, inserted into a job
Jobs / stages templateAzure PipelinesWhole jobs or stages
Variables templateAzure PipelinesA block of variables
extends templateAzure PipelinesThe entire pipeline shape — the governance mechanism
Task groupAzure Pipelines (classic only)A reusable set of tasks in the UI; becomes a template on migration
Variable groupAzure PipelinesShared values/secrets, optionally Key Vault-linked
Reusable workflowGitHub ActionsA whole workflow called with uses: at job level
Composite actionGitHub ActionsA sequence of steps bundled as one uses: step

Azure Pipelines templates

template templates/build-steps.yml
parameters:
  - name: buildConfiguration
    type: string
    default: Release
  - name: runTests
    type: boolean
    default: true

steps:
  - task: DotNetCoreCLI@2
    displayName: Restore
    inputs: { command: restore }

  - task: DotNetCoreCLI@2
    displayName: Build
    inputs:
      command: build
      arguments: '-c ${{ parameters.buildConfiguration }} --no-restore'

  # Compile-time conditional: the step does not EXIST when false
  - ${{ if eq(parameters.runTests, true) }}:
    - task: DotNetCoreCLI@2
      displayName: Test
      inputs: { command: test, arguments: '--no-build' }
consumer using the template
steps:
  - template: templates/build-steps.yml
    parameters:
      buildConfiguration: Debug
      runTests: false

# From ANOTHER repository:
resources:
  repositories:
    - repository: templates
      type: git
      name: Platform/pipeline-templates
      ref: refs/tags/v2.1.0     # pin the version!

steps:
  - template: build-steps.yml@templates

Caveat — extends is how you enforce, template is how you share

An extends template owns the whole pipeline and can restrict what the consuming pipeline is allowed to do (stepList parameters, allowed tasks). Combined with the required template check on an environment (see 3.2), it guarantees that every production deployment runs your mandated steps. A plain - template: insertion is opt-in and can simply be omitted.

GitHub reusable workflows and composite actions

reusable .github/workflows/reusable-build.yml
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
    outputs:
      artifact-name:
        value: ${{ jobs.build.outputs.name }}
    secrets:
      AZURE_CLIENT_ID:
        required: true

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      name: ${{ steps.pack.outputs.name }}
    steps:
      - uses: actions/checkout@v4
      - id: pack
        run: echo "name=app-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
caller calling it
jobs:
  call-build:
    # uses: at JOB level (not a step) — this is a reusable workflow
    uses: ./.github/workflows/reusable-build.yml
    with:
      environment: production
    secrets:
      AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
    # or:  secrets: inherit

  deploy:
    needs: call-build
    runs-on: ubuntu-latest
    steps:
      - run: echo "${{ needs.call-build.outputs.artifact-name }}"

Reusable workflow vs composite action

A reusable workflow is called at job level, brings its own jobs and runners, and can target environments. A composite action is called at step level and runs inside the caller's job on the caller's runner. "Share a set of steps used inside many jobs" → composite action. "Share an entire build-and-publish job across repos" → reusable workflow.

Variable groups and Key Vault

azure-pipelines.yml variable group linked to Key Vault
variables:
  - group: 'Prod-KeyVault-Linked'     # Library → Variable group → Link secrets from Key Vault
  - name: region
    value: uksouth

steps:
  - script: ./deploy.sh
    env:
      DB_PASSWORD: $(SqlAdminPassword)   # resolved from Key Vault at runtime

Alternatively fetch secrets inline with the AzureKeyVault@2 task — see Domain 4. Variable groups can be scoped to specific pipelines and require pipeline permissions before first use.

Checks and approvals with YAML environments

An environment is a named deployment target that provides: deployment history, resource traceability (Kubernetes/VM resources), and — critically — the place where approvals and checks attach. Full check list in 3.2.

azure-pipelines.yml environment with a resource
- stage: Production
  jobs:
    - deployment: DeployProd
      displayName: Deploy to production
      environment:
        name: production
        resourceType: Kubernetes      # or VirtualMachine
        resourceName: prod-cluster
      strategy:
        runOnce:
          preDeploy:
            steps: [ { script: ./precheck.sh } ]
          deploy:
            steps: [ { script: ./deploy.sh } ]
          routeTraffic:
            steps: [ { script: ./route.sh } ]
          postRouteTraffic:
            steps: [ { script: ./smoke-test.sh } ]
          on:
            failure:
              steps: [ { script: ./rollback.sh } ]
            success:
              steps: [ { script: ./cleanup.sh } ]

Lifecycle hooks are the rollback mechanism

preDeploydeployrouteTrafficpostRouteTrafficon: success | failure. Putting smoke tests in postRouteTraffic and the undo in on: failure is how you express automatic rollback declaratively — see 3.4.

3.3 rapid recap

The requirement says…Answer
Source is in TFVCAzure Pipelines (Actions cannot build TFVC)
Build must reach an on-prem SQL ServerSelf-hosted agent in that network
Bursty Kubernetes-based runners that scale to zeroActions Runner Controller (ARC)
Allow-list CI egress IPs on a firewallGitHub larger runners (static IPs) or self-hosted
Connect a GitHub repo to Azure Pipelines securelyAzure Pipelines GitHub App
Nightly build even with no commitsschedules: + always: true
Don't build on docs-only commitspaths: exclude: / paths-ignore:
Manual run where the user picks the environmentworkflow_dispatch inputs / pipeline parameters
Let a user add or remove a whole stage at queue timeParameters + ${{ if }} (compile time)
Run three OS test jobs at oncestrategy: matrix
Aggregate job must run even if tests faileddependsOn + condition: always() / if: always()
Two stages should run at the same timedependsOn: [] on the second stage
Share steps across many pipelinesYAML template / composite action
Share a whole build job across reposReusable workflow (workflow_call)
Force every prod pipeline to run a security scanextends template + required template check
Approval before production in a YAML pipelineApproval configured on the environment; use a deployment job
Pipeline needs secrets from Key VaultVariable group linked to Key Vault or AzureKeyVault@2