Azure Pipelines: Stages nur bei relevanten Dateiänderungen ausführen

8 Minuten zum Lesen
Azure Pipelines: Stages nur bei relevanten Dateiänderungen ausführen
Durch gezieltes Überspringen von Pipeline-Stages bei unverändertem Code sparst du Zeit, reduzierst Nebenwirkungen und schonst Agent-Ressourcen – ideal für Monorepo-Pipelines mit Infrastructure, Database und App-Deployments.

In komplexen Azure DevOps Pipelines, die beispielsweise Infrastructure-, Database- und App-Deployments kombinieren, laufen oft alle Stages durch – auch wenn nur der Anwendungscode geändert wurde. Das kostet Zeit, blockiert Agent-Ressourcen und kann ungewollte Nebenwirkungen verursachen. Mit intelligenter Dateiänderungs-Erkennung und conditional Stage Execution kannst du deine Pipeline optimieren: Nur die Stages laufen, die wirklich benötigt werden.

Warum Stages überspringen?

Die Optimierung von Pipeline-Durchläufen bringt mehrere Vorteile:

  • Du sparst Zeit, denn ein Infrastructure-Deployment kann 10-15 Minuten dauern, selbst wenn nur App-Code geändert wurde. Bei mehreren Environments summiert sich das schnell auf 30-60 Minuten Wartezeit pro Commit.

  • Du schonst Agent-Ressourcen, denn in Teams mit parallelen Pipelines sind verfügbare Agents oft knapp. Übersprungene Stages geben Kapazitäten für andere Builds frei.

  • Du vermeidest Nebenwirkungen, denn unnötige Infrastructure-Updates können zu unerwarteten Änderungen führen oder Kosten verursachen (z.B. beim Neustart von Azure Resources).

  • Du erhältst eine bessere Pipeline-Übersicht, denn die Logs zeigen deutlich, welche Komponenten tatsächlich deployed wurden.

Git Diff: Grundlagen der Dateiänderungs-Erkennung

Die Basis für intelligente Stage-Ausführung ist die Analyse der Dateiänderungen mittels git diff. Dabei gibt es zwei wichtige Szenarien:

Szenario 1: Main Branch (nach Merge)

Beim Merge in den Main Branch, besonders bei Squash Merges, werden alle Feature-Branch-Commits zu einem zusammengefasst. Hier vergleichst du den aktuellen Commit mit dem vorherigen: HEAD~..HEAD. Der Diff zeigt alle Änderungen, die der Merge mitgebracht hat.

Szenario 2: Feature Branch / Pull Request

Bei Feature Branches oder Pull Requests vergleichst du gegen den Target Branch: origin/main..HEAD. Dies zeigt alle Änderungen des Feature Branches gegenüber dem Ziel-Branch und ist wichtig für PR Validations, die vor dem Merge laufen.

Der Git Diff Command liefert eine Liste aller geänderten Dateipfade:

git diff --name-only <base>..<head>

Mit Glob-Patterns wie eng/infrastructure/** kannst du dann filtern, ob relevante Dateien betroffen sind.

Das PowerShell-Script zur Änderungs-Erkennung

Das PowerShell-Script Test-GitDiffMatchesPattern.ps1 übernimmt die Logik zur Erkennung von Dateiänderungen:

param(
    # Suchmuster als Array von Glob-Patterns
    [Parameter(Mandatory = $true)]
    [string[]]$SearchPatterns,
    
    # Basis- und Head-Commits (optional)
    [Parameter(Mandatory = $false)]
    [string]$BaseCommit = 'HEAD~',
    
    [Parameter(Mandatory = $false)]
    [string]$HeadCommit = 'HEAD'
)

# Branch-Detection
$sourceBranch = $env:BUILD_SOURCEBRANCHNAME
if ($sourceBranch -eq "main") {
    $baseCommit = "HEAD~"
    $headCommit = "HEAD"
    Write-Host "Comparing main branch: $baseCommit..$headCommit"
} else {
    $baseCommit = "origin/main"
    $headCommit = "HEAD"
    Write-Host "Comparing feature branch: $baseCommit..$headCommit"
}

# Git Diff ausführen
$changedFiles = git diff --name-only $baseCommit $headCommit

# Pattern Matching
foreach ($file in $changedFiles) {
    foreach ($pattern in $searchPatterns) {
        # Glob Pattern zu Regex konvertieren
        # ** → .* (beliebige Verzeichnisebenen)
        # * → [^/]* (innerhalb einer Verzeichnisebene)
        $regexPattern = $pattern -replace '\*\*', '.*' -replace '\*', '[^/]*'
        
        if ($file -match $regexPattern) {
            Write-Host "Match found: $file matches $pattern"
            return $true
        }
    }
}

Write-Host "No matches found"
return $false

Wichtige Aspekte:

  • Bei der Pattern-Normalisierung werden führende Slashes entfernt und Glob-Wildcards zu Regex konvertiert.
  • Die Wildcard-Unterstützung funktioniert so, dass * innerhalb einer Verzeichnisebene matcht, während ** rekursiv über alle Ebenen matcht.
  • Der Return Value ist ein Boolean. Das Script gibt $true bei einem Match zurück, sonst $false.
Glob PatternBeschreibungBeispiel-Match
eng/infrastructure/**Alle Dateien unter infrastructureeng/infrastructure/main.bicep
eng/infrastructure/*.bicepNur .bicep-Dateien direkt in infrastructureeng/infrastructure/main.bicep (nicht in Unterordnern)
src/app/**/*.csAlle C#-Dateien in appsrc/app/Controllers/HomeController.cs

Das Azure Pipeline Template für File-Change-Detection

Das Template check-files-changed.template.yml kapselt den Script-Aufruf und erstellt eine Output Variable:

parameters:
  # Name der Output Variable 
  - name: outputVariableName
    type: string
  
  # Force-Flag zum manuellen Überschreiben
  # Dies kann dazu genutzt werden, um die Stage immer auszuführen
  # auch wenn keine Dateien geändert wurden
  - name: force
    type: boolean
    default: false
  
  # Suchmuster als Array von Glob-Patterns
  - name: searchPatterns
    type: object

steps:
  - pwsh: |
      if ('${{ parameters.force }}' -ieq 'True') {
        Write-Host "##[section]Force flag is set. Marking files as changed."
        Write-Host "##vso[task.setvariable variable=HasChanged;isOutput=true]Yes"
        exit 0
      }

      # Azure Pipelines gibt das Array als JSON-String weiter, damit muss es konvertiert werden.
      $searchPatterns = ConvertFrom-Json '${{ convertToJson(parameters.searchPatterns) }}'

      # Pfad zum Script
      $scriptPath = "$($env:BUILD_SOURCESDIRECTORY)/eng/scripts/Test-GitDiffMatchesPattern.ps1"
      
      $hasChanges = & $scriptPath -SearchPatterns $searchPatterns
      $variableValue = $hasChanges ? 'Yes' : 'No'

      Write-Host "##[section]Files changed: $variableValue"

      # Output Variable setzen
      # Auf diese Weise kann die Variable in späteren Stages referenziert werden
      Write-Host "##vso[task.setvariable variable=HasChanged;isOutput=true]$variableValue"
    name: ${{ parameters.outputVariableName }}
    displayName: 'Check Files Changed for ${{ parameters.outputVariableName }}'

Template-Features:

  • Das Force-Flag erlaubt manuelles Überschreiben für Testing (z.B. bei Force-Deployments).
  • Die Output Variable HasChanged wird mit isOutput=true gesetzt. Die möglichen Werte sind 'Yes' oder 'No'.
  • Bei der JSON-Serialisierung wird searchPatterns als Array mittels convertToJson() übergeben.
  • Der benannte Step ermöglicht durch den name Parameter die Referenzierung in späteren Stages.

Output Variables und Dependencies nutzen

Um die Output Variable in späteren Stages zu nutzen, benötigst du zwei Dinge: dependsOn und die richtige Syntax für dependencies.

Im Build Job definierst du die File-Change-Checks:

jobs:
  - job: Build_App
    steps:
      - template: /eng/templates/check-files-changed.template.yml
        parameters:
          outputVariableName: 'InfrastructureChanged'
          searchPatterns:
            - 'eng/infrastructure/**'

In der Deployment Stage nutzt du die Output Variable:

- stage: DEV_Infrastructure_Deploy
  displayName: 'DEV Infrastructure Deploy'
  dependsOn: Build
  condition: and(succeeded(), eq(dependencies.Build.outputs['Build_App.InfrastructureChanged.HasChanged'], 'Yes'))
  jobs:
    - job: Deploy
      steps:
        - script: echo "Deploying infrastructure changes"

Wichtige Syntax-Elemente:

  • dependsOn: Build ist zwingend erforderlich, denn ohne diese Abhängigkeit ist dependencies nicht verfügbar.
  • Die Syntax lautet dependencies.<STAGE>.outputs['<JOB>.<STEP>.<VARIABLE>']. Dabei ist Build der Stage-Name (bei Single-Stage-Pipelines oft implizit), Build_App ist der Job-Name, InfrastructureChanged ist der Step-Name (outputVariableName) und HasChanged ist der Variable-Name (im Template gesetzt).
  • Willst du auf die Variable innerhalb einer Job-Condition zugreifen, ist der Syntax folgender: stageDependencies.<STAGE>.outputs['<JOB>.<STEP>.<VARIABLE>'].
  • and(succeeded(), eq(...)) kombiniert zwei Bedingungen. Die Stage wird nur ausgeführt, wenn die vorherige Stage erfolgreich war und die Variable den Wert 'Yes' hat.

Für Stage-zu-Stage Dependencies mit mehreren Abhängigkeiten:

- stage: TEST_Infrastructure_Deploy
  dependsOn:
    - DEV_Infrastructure_Deploy
    - Build
  condition: and(succeeded(), eq(dependencies.Build.outputs['Build_App.InfrastructureChanged.HasChanged'], 'Yes'))

Conditional Stage Execution: Das Gesamtbild

Ein vollständiges Beispiel mit mehreren Stages zeigt das Zusammenspiel der einzelnen Komponenten:

stages:
  - stage: Build
    jobs:
      - job: Build_App
        steps:
          # Check Infrastructure Changes
          - template: /eng/templates/check-files-changed.template.yml
            parameters:
              outputVariableName: 'InfrastructureChanged'
              searchPatterns:
                - 'eng/infrastructure/**'
          
          # Check Database Changes
          - template: /eng/templates/check-files-changed.template.yml
            parameters:
              outputVariableName: 'DatabaseChanged'
              searchPatterns:
                - 'eng/database/**'
                - 'src/**/Migrations/**'
          
          # Check App Changes
          - template: /eng/templates/check-files-changed.template.yml
            parameters:
              outputVariableName: 'AppChanged'
              searchPatterns:
                - 'src/**'
          
          # Build steps...
          - script: echo "Building application"

  - stage: DEV_Infrastructure
    dependsOn: Build
    condition: and(succeeded(), eq(dependencies.Build.outputs['Build_App.InfrastructureChanged.HasChanged'], 'Yes'))
    jobs:
      - job: Deploy_Infra
        steps:
          - script: echo "Deploying infrastructure to DEV"

  - stage: DEV_Database
    dependsOn: Build
    condition: and(succeeded(), eq(dependencies.Build.outputs['Build_App.DatabaseChanged.HasChanged'], 'Yes'))
    jobs:
      - job: Deploy_DB
        steps:
          - script: echo "Deploying database to DEV"

  - stage: DEV_App
    dependsOn:
      - Build
      - DEV_Infrastructure
      - DEV_Database
    condition: and(succeeded(), eq(dependencies.Build.outputs['Build_App.AppChanged.HasChanged'], 'Yes'))
    jobs:
      - job: Deploy_Application
        steps:
          - script: echo "Deploying app to DEV"

Ohne Conditions würden alle Stages immer ausgeführt. Mit Conditions überspringt Azure DevOps Stages automatisch, wenn die Bedingung false ist. Übersprungene Stages werden in der UI als “Skipped” angezeigt, und nachfolgende Stages mit dependsOn auf übersprungene Stages laufen trotzdem (außer sie haben eigene Conditions).

Alternative Ansätze und Best Practices

Es gibt verschiedene Wege, um ähnliche Ziele zu erreichen:

AnsatzVorteileNachteileWann nutzen?
Conditional Stages (dieser Artikel)• Flexible Kontrolle
• Eine Pipeline
• Vollständiger History
• Setup-Aufwand
• Komplexere Conditions
Monorepos mit mehreren Deployment-Typen
Separate Pipelines• Klare Trennung
• Einfache Trigger
• Mehrere Pipelines verwalten
• Fragmentierte History
Klare Komponenten-Grenzen
Path Triggers• Native Azure DevOps Feature
• Einfach zu konfigurieren
• Nur für Pipeline-Start
• Keine Stage-Level-Kontrolle
Trigger verschiedener Pipelines
Manual Stages• Volle Kontrolle
• Keine Automatisierung nötig
• Manueller Aufwand
• Verzögert Deployments
Production-Deployments mit Approval

Best Practices für die Implementierung:

Nutze Conservative Patterns. Lieber zu viel als zu wenig deployen. Bei Unsicherheit solltest du die Stage ausführen. Implementiere ein Force-Flag für Testing, das manuelle Overrides ermöglicht (z.B. force: true als Parameter). Dokumentiere deine Pattern-Entscheidungen mit Kommentaren in der YAML und erkläre, warum bestimmte Patterns gewählt wurden. Prüfe regelmäßig die Pipeline-Logs, um zu sehen, welche Stages übersprungen werden. Beachte Shared Files, die mehrere Komponenten betreffen können, und passe deine Patterns entsprechend an.

# Beispiel mit Force-Flag für manuelle Deployments
- template: /eng/templates/check-files-changed.template.yml
  parameters:
    outputVariableName: 'InfrastructureChanged'
    force: ${{ parameters.forceInfrastructureDeploy }} # Pipeline-Parameter
    searchPatterns:
      - 'eng/infrastructure/**'

Intelligentes Pipeline-Management für effiziente Deployments

Mit Conditional Stage Execution basierend auf Git Diff sparst du messbar Zeit und Ressourcen in komplexen Pipelines. Der Ansatz eignet sich besonders für Monorepos mit verschiedenen Deployment-Komponenten, wo nicht bei jeder Code-Änderung die gesamte Infrastructure neu deployed werden muss.

Git Diff erkennt automatisch, ob relevante Dateien geändert wurden. Dies funktioniert unterschiedlich für Main vs. Feature Branch. Output Variables transportieren die Information zwischen Build und Deployment Stages. dependsOn ist zwingend erforderlich, damit die dependencies-Syntax funktioniert. Conservative Patterns und Force-Flags geben dir Kontrolle bei Edge Cases.

Empfehlung: Starte mit konservativen Patterns und verfeinere sie iterativ. So kannst du erst Erfahrung mit dem Verhalten sammeln. Dokumentiere deine Pattern-Entscheidungen im Code und überwache die Pipeline-Logs regelmäßig.

Wie optimierst du deine Azure Pipelines? Nutzt du ähnliche Ansätze oder hast du andere Strategien für effizientes Pipeline-Management? Diskutiere mit uns auf LinkedIn.

Autoren

Florian Bader

Florian Bader

Florian ist Solution Architect und Microsoft Most Valuable Professional (MV) für Azure IoT und DevOps mit langjähriger Erfahrung im Bereich DevOps, Cloud und Digitalisierung. Er unterstützt Unternehmen dabei, effiziente und effektive Lösungen zu entwickeln, die ihre digitalen Projekte nachhaltig zum Erfolg führen.