Azure Pipelines'ta Variable, Parameter ve Expression'lar: ${{ }}, $( ) ve $[ ] Farkı
Azure Pipelines'ta ${{ }} dosya işlenirken, $( ) her task'tan hemen önce, $[ ] ise job veya stage başlarken değerlendirilir; hangisini seçtiğin değerin o anda var olup olmadığını belirler. Bookstore için sürümü bir job'da hesaplayıp output olarak taşıyor, secret'ların env'e neden kendiliğinden gelmediğini, runtime parameter'ları, variable group'ları ve CI, zamanlanmış ve pipeline completion trigger'larını görüyoruz.
Sürüm notu: Bu yazıdaki script'ler ve .NET 10 (SDK 10.0.404) build'i yerelde denenmiştir; pipeline dosyası yq 4.54 ile ayrıştırılarak denetlenmiştir. Değişkenlerin, expression'ların ve trigger'ların Azure Pipelines içindeki davranışını anlatırken Microsoft Learn'deki resmî belgelerden (Kasım 2026 sürümleri) yararlanılmıştır.
Önce Tahmin Et
Ayşe, Bookstore pipeline'ında bir değişkenin değerini çalışma sırasında değiştirip sonraki step'te okumak istiyor. Değişkenin başlangıç değeri initial; ilk step onu run'ın numarasıyla eziyor, ikinci step aynı değişkeni iki farklı yazımla yazdırıyor:
variables:
apiImageTag: initial
steps:
- script: echo "##vso[task.setvariable variable=apiImageTag]$(Build.BuildId)"
displayName: Override tag
- script: |
echo "template: ${{ variables.apiImageTag }}"
echo "macro: $(apiImageTag)"
displayName: Print tag
Run'ın numarası 1287 olsun. İkinci step'in log'unda iki satırda ne yazar?
Cevap: template: initial ve macro: 1287. ${{ variables.apiImageTag }} bir template expression ve pipeline daha agent'a gitmeden, dosya işlenirken hesaplanıyor; o anda değişkenin değeri hâlâ dosyadaki initial. Bu satır agent'a vardığında artık bir expression değil, düz metin. $(apiImageTag) ise macro syntax: agent her step'i çalıştırmadan hemen önce değeri yerine koyuyor ve ilk step logging command ile değeri çoktan değiştirmiş oluyor. Microsoft Learn'deki "Define variables" sayfası aynı davranışı kendi örneğiyle anlatıyor. Bu yazının geri kalanı bu farkın neden önemli olduğu üzerine.
Üç Yazım, Üç Zaman: ${{ }}, $( ) ve $[ ]
Azure Pipelines bir run'ı üç aşamada işler. Önce YAML (girintiyle yapı kuran metin formatı) metni üzerinde çalışır: template'leri açar, ${{ }} ile yazılmış template expression'ları hesaplar. Sonra stage ve job'ları sıraya koyar, koşulları değerlendirir. En son agent her step'ten hemen önce $( ) macro'larını değiştirir. $[ ] runtime expression'ları ise çalışma sırasında, ilgili job ya da stage başlamadan değerlendirilir. Farkları tek tabloda toplayalım:
| Yazım | Ne zaman işlenir | Nerede kullanılır | Değişken yoksa |
|---|---|---|---|
${{ variables.x }}, ${{ parameters.x }} | Dosya işlenirken, yani compile time'da | Anahtar ya da değer; koşullu ve döngülü ekleme | Boş string |
$(x) | Çalışma anında, her task'tan hemen önce | Yalnızca değer; task input'ları ve script gövdesi | Metin olduğu gibi kalır: $(x) |
$[ variables.x ] | Çalışma anında, job veya stage başlarken | Yalnızca değer ve değerin tamamı; değişken tanımı, koşul | Boş string |
Pratikte seçim şöyle yapılıyor. Parametreye ya da dosyada sabit duran bir değere göre YAML'ın şeklini değiştireceksen (bir step'i eklemek, bir job'ı çoğaltmak, bir anahtarın adını belirlemek) ${{ }} kullanırsın. Bir task'a değer verecek ya da script içinde değeri okuyacaksan $( ) yeterli; secret'lar ve önceden tanımlı değişkenler için de doğru yazım bu. Başka bir job'ın output'unu okuyacaksan ya da bir koşulu çalışma sırasındaki değere göre kuracaksan $[ ] gerekir. İki kural sık unutuluyor: macro'lar yalnızca stages, jobs ve steps içindeki task input'larında açılıyor, trigger ya da resources gibi dosya işlenirken çözülen anahtarlarda açılmıyor; runtime expression ise değerin tamamını kaplamak zorunda, key: $[ variables.x ]-suffix geçerli değil.
Variable Scope'ları ve Önceden Tanımlı Değişkenler
Değişkeni üç seviyede tanımlayabilirsin: kökte (bütün pipeline), stage altında (yalnız o stage) ve job altında (yalnız o job). Aynı ad birden fazla seviyede varsa en dar olan kazanır: job seviyesi stage'i, stage seviyesi kökü ezer. Değişkenlerin hepsi string'dir; true yazsan da karşılaştırmada 'true' metni olarak ele alınır.
Secret olmayan her değişken step'lere environment variable olarak da gelir. Ad büyük harfe çevrilir ve noktalar alt çizgiye döner: Build.SourceBranch bash'te $BUILD_SOURCEBRANCH olur. Adında endpoint, input, secret, path ya da securefile ile başlayan değişkenler task'lara ve script'lere hiç ulaşmaz; bunlar sistemin kendine ayırdığı önekler.
Azure Pipelines her run'da onlarca değişkeni kendisi tanımlar. Bu seride en çok kullanacaklarımız: Build.SourceBranch (tam ref, ör. refs/heads/main), Build.SourceBranchName (yalnızca son parça), Build.BuildId (run'ın sayısal kimliği), Build.Reason (run'ı neyin başlattığı: Manual, IndividualCI, BatchedCI, Schedule, PullRequest, BuildCompletion, ResourceTrigger gibi), Build.SourcesDirectory ve Pipeline.Workspace. Bir koşulda nokta içeren bu adlar için index yazımı gerekiyor: variables['Build.SourceBranch']; variables.Build.SourceBranch çalışmaz.
Logging Command ile Değer Üretmek ve Job'lar Arasında Taşımak
Bu yazıdan sonra Bookstore'un API'si, Angular çıktısı ve ileride üreteceğimiz paket aynı run'da aynı sürüm numarasını taşıyacak. Sürümü bir kez, ayrı bir job'da hesaplayıp diğer job'lara output olarak veriyoruz. Önce Directory.Build.props'a <VersionPrefix>1.0.0</VersionPrefix> ekledik, sonra şu script'i yazdık:
#!/usr/bin/env bash
# Her run'da sürümü bir kez hesaplar ve output değişkeni olarak yayınlar.
# Sürüm politikası (SemVer, changelog) burada konu dışı; yalnızca branch'e göre bir ek koyar.
set -euo pipefail
prefix=$(sed -n 's:.*<VersionPrefix>\(.*\)</VersionPrefix>.*:\1:p' Directory.Build.props)
branch="${BUILD_SOURCEBRANCH:-refs/heads/local}"
build_id="${BUILD_BUILDID:-0}"
case "$branch" in
refs/heads/main) version="${prefix}-main.${build_id}" ;;
refs/heads/release/*) version="${prefix}-rc.${build_id}" ;;
*) version="${prefix}-ci.${build_id}" ;;
esac
echo "App version: ${version}"
echo "##vso[task.setvariable variable=appVersion;isOutput=true]${version}"
Script önceden tanımlı değişkenleri environment variable olarak okuyor; bu sayede agent'ın gönderdiği değerleri yerelde de elle verip deneyebiliyoruz:
$ BUILD_SOURCEBRANCH=refs/heads/main BUILD_BUILDID=1287 bash scripts/set-version.sh
App version: 1.0.0-main.1287
##vso[task.setvariable variable=appVersion;isOutput=true]1.0.0-main.1287
$ BUILD_SOURCEBRANCH=refs/heads/release/1.0 BUILD_BUILDID=1287 bash scripts/set-version.sh
App version: 1.0.0-rc.1287
##vso[task.setvariable variable=appVersion;isOutput=true]1.0.0-rc.1287
$ BUILD_SOURCEBRANCH=refs/heads/feature/cart-badge BUILD_BUILDID=1287 bash scripts/set-version.sh
App version: 1.0.0-ci.1287
##vso[task.setvariable variable=appVersion;isOutput=true]1.0.0-ci.1287
Yerelde son satır sıradan bir metin. Agent'ta ise task.setvariable komutu appVersion adında bir değişken tanımlıyor ve isOutput=true onu başka job'ların okuyabileceği bir output yapıyor. SemVer'in kendisi ve hangi ekin ne anlama geleceği Versioning ve Release Management serisinin konusu; burada yalnızca değeri taşımanın mekaniğine bakıyoruz. Pipeline'ın job kısmı şöyle oldu:
jobs:
- job: Version
displayName: Compute version
steps:
- checkout: self
fetchDepth: 1
- script: bash scripts/set-version.sh
name: setVersion
displayName: Compute app version
- job: Api
displayName: Build and test API
dependsOn: Version
variables:
appVersion: $[ dependencies.Version.outputs['setVersion.appVersion'] ]
steps:
- checkout: self
- task: UseDotNet@2
displayName: Install .NET SDK from global.json
inputs:
packageType: sdk
useGlobalJson: true
- script: dotnet restore Bookstore.slnx
displayName: Restore
- script: dotnet build Bookstore.slnx --no-restore -c $(buildConfiguration) -p:Version=$(appVersion)
displayName: Build $(appVersion)
Üç ayrıntı önemli. Output'u üreten step'e name: verilmeli, çünkü output'un tam adı setVersion.appVersion. Okuyan job dependsOn: Version ile bağlanmalı; bağımlılık yoksa dependencies.Version boş gelir. Değer job seviyesinde bir değişkene $[ ] ile atanmalı; ondan sonra step'lerde düz $(appVersion) olarak okunuyor. Aynı job içinde output'u okumak istersen $(setVersion.appVersion) yeterli. Farklı bir stage'deki job'dan okumak içinse yazım $[ stageDependencies.Build.Version.outputs['setVersion.appVersion'] ] oluyor; belgelere göre bu değer yalnızca hemen sonraki stage'de hazır, daha ileri bir stage okuyacaksa ona dependsOn ile açıkça bağlanması gerekiyor.
Build step'i sürümü MSBuild'e -p:Version= ile veriyor. Aynı komutu yerelde çalıştırıp derlenen DLL'in (derlenmiş .NET kütüphane dosyası) içindeki sürüm metnine bakınca değeri görüyoruz:
$ dotnet build Bookstore.slnx --no-restore -c Release -p:Version=1.0.0-main.1287
Build succeeded.
0 Warning(s)
0 Error(s)
$ strings src/Bookstore.Api/bin/Release/net10.0/Bookstore.Api.dll | grep -E "^1\.0\.0"
1.0.0.0
1.0.0-main.1287
İlk satır assembly sürümü (yalnızca sayısal kısım), ikincisi informational version; API'nin sağlık ya da sürüm endpoint'i ileride bu değeri gösterebilir.
Secret'lar: Neden Kendiliğinden Gelmiyorlar
İlk yazıdaki start-test-sql.sh, connection string'i issecret=true ile secret bir değişken olarak yayınlıyordu ve test step'i onu env: altında açıkça bağlıyordu. Bunun nedeni belgelerde net: secret değişkenler, normal değişkenlerin aksine, environment variable olarak step'lere otomatik aktarılmıyor. Kökteki variables: altında DB: $(mySecret) gibi bir eşleme de işe yaramıyor; secret'ı env'e taşımanın tek doğru yolu, onu kullanacak step'in env: bloğu. Böylece hangi step'in hangi secret'ı gördüğü dosyada açıkça okunuyor.
Secret'ı ilk kez pipeline'a koymanın yolu YAML değil. Pipeline düzenleme ekranındaki Variables bölümünde "Keep this value secret" işaretli bir değişken ya da Library'deki bir variable group kullanılıyor. Secret'lar dinlenirken 2048 bit RSA (yaygın bir açık anahtarlı şifreleme algoritması) anahtarıyla şifreleniyor ve agent'a yalnızca run sırasında gidiyor. Maskeleme konusunda iki sınır var. Azure Pipelines bir secret'ın parçalarını maskelemiyor: değerin tamamı *** olur ama bir kısmı log'a düşerse olduğu gibi görünür. Bu yüzden secret'a JSON gibi yapılandırılmış veri koymamak gerekiyor. İkincisi, işletim sistemleri çalıştırılan komutların argümanlarını loglayabiliyor; secret'ı komut satırı argümanı olarak değil environment variable olarak vermek bu yüzden öneriliyor.
İlk yazıdaki script'te bu kuralı uyguladık: docker run -e MSSQL_SA_PASSWORD değeri yazmadan, mevcut environment'tan kopyalıyor. Script'i, kendisine gelen argümanları bir dosyaya yazan küçük bir sahte docker ile yerelde koşturduğumuzda, gerçekten gönderilen komutlar şunlar:
docker run -d --name bookstore-test-sql -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD -p 1433:1433 mcr.microsoft.com/mssql/server:2025-latest
docker exec -e SQLCMDPASSWORD bookstore-test-sql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -Q SELECT 1
docker exec -e SQLCMDPASSWORD bookstore-test-sql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -Q SELECT 1
docker exec -e SQLCMDPASSWORD bookstore-test-sql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -Q SELECT 1
Şifre hiçbir argümanda yok. Script ayrıca şifreyi tek başına task.setsecret ile maskeleme listesine ekliyor; çünkü secret olarak işaretlenen değer connection string'in tamamı, şifre ise onun bir parçası ve parçalar maskelenmiyor.
Runtime Parameter'lar: Elle Çalıştırırken Seçim Yaptırmak
Değişkenler çalışma sırasında değişebilir; parameter'lar değişmez. Pipeline'ın en üstünde tanımlanan bir parameter, run başlatılırken bir kez seçilir, template'ler açılırken yerine konur ve ondan sonra sabittir. Bunun karşılığında tip güvenliği veriyor: string, number, boolean, object ve step, stepList, job, jobList, stage, stageList gibi YAML parçası alan tipler var; values: ile izin verilen değerleri sınırlayabiliyorsun. Bookstore pipeline'ının başına iki parameter koyduk:
parameters:
- name: buildConfiguration
displayName: Build configuration
type: string
default: Release
values:
- Release
- Debug
- name: runIntegrationTests
displayName: Run integration tests
type: boolean
default: true
variables:
buildConfiguration: ${{ parameters.buildConfiguration }}
Web arayüzünde "Run pipeline" dediğinde bu ikisi bir açılır liste ve bir onay kutusu olarak çıkıyor; trigger'la başlayan run'lar varsayılan değerleri kullanıyor. Parameter'ı bir değişkene aktarmak, dosyanın geri kalanında $(buildConfiguration) yazmaya devam etmemizi sağladı. Boolean parameter ise step'leri tamamen eklemek ya da çıkarmak için kullanılıyor:
- ${{ if eq(parameters.runIntegrationTests, true) }}:
- script: bash scripts/start-test-sql.sh
displayName: Start SQL Server for integration tests
- script: dotnet test --solution Bookstore.slnx --no-build -c $(buildConfiguration)
displayName: Test
env:
ConnectionStrings__BookstoreDb: $(TestDbConnection)
Bu bir condition değil, koşullu ekleme: kutu boş bırakılırsa bu iki step açılmış pipeline'da hiç yer almıyor, run ekranında "skipped" olarak bile görünmüyor. Parameter'lar secret taşıyamıyor; secret gereken her şey değişkende kalmalı.
Deploy pipeline'larında tipik kullanım environment seçtirmek. Aşağıdaki parça altıncı yazıda kullanacağımız biçimin özeti: seçilen değere göre doğru variable group'u dosya işlenirken bağlıyor.
parameters:
- name: environment
type: string
default: dev
values:
- dev
- staging
- production
variables:
- group: bookstore-${{ parameters.environment }}
- name: buildConfiguration
value: Release
Burada ${{ }} kullanmak zorunlu: variable group bir resource ve resource'lar stage başlamadan yetkilendiriliyor; bu aşamada $( ) ile okunan değişkenler henüz yok. Aynı sebeple environment ve service connection adlarını da değişkenden okuyamıyorsun.
Variable Group'lar ve Library
Birden fazla pipeline'ın paylaştığı değerler için Pipelines altındaki Library'de variable group oluşturuluyor. YAML'da - group: bookstore-ci diye bağlanıyor; bu durumda variables bloğu yukarıdaki gibi liste biçimine geçiyor ve normal değişkenler name/value çiftiyle yazılıyor. Aynı adı taşıyan değişken iki grupta varsa dosyada sonra gelen grup kazanıyor. Grubun içindeki secret'lar protected resource sayılıyor: bir pipeline grubu ilk kez kullandığında, grubun Pipeline permissions sekmesinden yetki verilene kadar run durup izin istiyor. "Open access" seçeneği project'teki bütün pipeline'lara izin veriyor; içinde secret olmayan gruplar için makul, secret'lı gruplar için değil. Bir grubu doğrudan Azure Key Vault'taki secret'lara bağlamak da mümkün; bunu Key Vault'tan connection string okuyacağımız yedinci yazıda kullanacağız. Key Vault'un kendisi Configuration ve Secret Management serisinde anlatıldı.
Trigger Türleri
CI trigger'ı dosyamızda şu hâle geldi:
trigger:
batch: true
branches:
include:
- main
- release/*
paths:
exclude:
- web
- README.md
branches hangi branch'lere push'un run başlatacağını söylüyor. paths.exclude, değişikliklerin tamamı bu yolların altındaysa run başlatmıyor: bu pipeline yalnızca API'yi derlediği için Angular klasörüne ya da README'ye dokunan bir commit boşuna kuyruk tutmuyor. Yollar repository köküne göre yazılıyor, büyük küçük harfe duyarlı ve içinde değişken kullanılamıyor, çünkü trigger değerlendirildiğinde değişkenler henüz yok. batch: true ise bir run sürerken gelen push'ları biriktiriyor; o run bitince birikenlerin hepsi için tek bir run başlıyor. Belgeler bunu çok stage'li ve onay bekleyen pipeline'larda dikkatle kullanmayı öneriyor, çünkü yeni run, önceki run bitene kadar başlamıyor.
Pull request trigger'ı ilk yazıda konuştuk: Azure Repos'ta branch policy, GitHub'da pr: bloğu. Zamanlanmış trigger için schedules kullanılıyor:
schedules:
- cron: "0 2 * * 1-5"
displayName: Nightly integration run
branches:
include:
- main
always: false
Cron ifadeleri her zaman UTC'ye (eşgüdümlü evrensel saat) göre; "0 2" Türkiye saatiyle 05:00 demek. always: false varsayılan değer: son başarılı zamanlanmış run'dan beri kodda değişiklik yoksa run başlamıyor. Gece testlerini değişiklik olmasa da koşturmak istiyorsan true yapman gerekiyor. Zamanlamalar ana pipeline dosyasında olmalı, template'e konamıyor. Web arayüzünden eklenmiş bir zamanlama varsa YAML'dakileri geçersiz kılıyor; eski bir pipeline'da cron'u değiştirip etkisini göremiyorsan önce oraya bak.
Dördüncü tür pipeline completion trigger: bir pipeline, başka bir pipeline'ın bitmesiyle başlıyor. Örneğin beşinci yazıda ayrı bir pipeline'da yayınlayacağımız contract paketinin her başarılı build'inden sonra API pipeline'ını yeniden çalıştırmak istersen:
resources:
pipelines:
- pipeline: contracts
source: bookstore-contracts
trigger:
branches:
include:
- main
source, tetikleyen pipeline'ın Azure DevOps'taki adı; pipeline ise bu dosyada ona verdiğin takma ad. Belgeler böyle başlayan bir run için Build.Reason değerini ResourceTrigger ya da BuildCompletion olarak veriyor; koşul yazarken ikisini birden hesaba kat.
Üretimde Dikkat
Sürümü ayrı bir job'da hesaplamanın bir bedeli var: o job da bir VM açıyor ve bir parallel job slot'u harcıyor. Tek parallel job'lı bir organization'da bu, run'a VM açılış süresi kadar ek süre demek. Karşılığında API, Angular ve paket job'ları aynı değeri tek bir yerden alıyor; üçüncü yazıda bu job'ı template'e taşıyacağız. Sürüm job'ını küçük tutmak için checkout'u fetchDepth: 1 ile yalnızca son commit'e indirdik.
Değişkenlerin nereden geldiğini kayıt altına al. Aynı ad kökte, bir variable group'ta, pipeline ayarlarındaki UI değişkenlerinde ve bir logging command'da tanımlanabiliyor; hangisinin kazandığını bulmak zaman alıyor. Değişkeni run başlatırken değiştirilebilir yapmak ("Let users override this value when running this pipeline") pipeline ayarlarından yapılıyor; YAML'daki bir değişkenin değerini dışarıdan değiştirmek isteyen herkes için runtime parameter daha güvenli, çünkü values: ile sınırlanıyor ve tipi belli. Değişkenlerin dışarıdan ayarlanmasını kısıtlamak sekizinci yazının konusu.
Zamanlanmış run'ların sessizce durmasına dikkat et. Pipeline devre dışı bırakıldığında YAML'daki zamanlamalar güncellenmiyor ve always: false yüzünden değişiklik olmayan günlerde gece run'ı başlamıyor. Gece testlerinin son ne zaman koştuğunu haftalık olarak kontrol etmek, kimsenin fark etmediği bir boşluğu kapatıyor.
Sık Düşülen Tuzaklar
Bulunamayan bir macro'nun olduğu gibi kalması. $(appVersion), değişken yoksa metin olarak kalıyor ve bash bu metni bir komut çalıştırma ifadesi olarak okuyor. Output'u bağlamayı unuttuğun durumu yerelde aynı komutla canlandırınca sonuç şu (yolu kısalttım):
$ bash -c 'dotnet build Bookstore.slnx --no-restore -c Release -p:Version=$(appVersion)'
bash: appVersion: command not found
…targets(226,5): error MSB4044: The "GetAssemblyVersion" task was not given a value for the required parameter "NuGetVersion".
Build FAILED.
Hata mesajı sürümden değil MSBuild'in bir task'ından bahsediyor; asıl ipucu ilk satırdaki command not found. Pipeline log'unda bu satırı görürsen önce değişkenin gerçekten tanımlı olup olmadığına bak.
condition yazınca varsayılanı ezmek. Koşulu olmayan bir step ya da job yalnızca önceki her şey başarılıysa çalışır; bu succeeded() varsayılanı. Kendi koşulunu yazdığında bu varsayılan tamamen yerinden ediliyor: condition: eq(variables['Build.SourceBranch'], 'refs/heads/main') yazılmış bir deploy step'i, önceki testler kırılsa bile main'de çalışır. Doğrusu and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')).
Branch adını kısa yazmak. Build.SourceBranch Azure Repos'ta her zaman refs/heads/main biçiminde; eq(variables['Build.SourceBranch'], 'main') hiç doğru olmuyor. Kısa ad istiyorsan Build.SourceBranchName var, ama release/1.0 gibi slash içeren branch'lerde yalnızca son parçayı (1.0) verdiğini unutma. Karşılaştırmalar büyük küçük harfe duyarsız, yani 'True' ile 'true' arasında fark yok.
Template expression'da runtime değişkeni beklemek. ${{ variables.appVersion }} her zaman boş gelir, çünkü değer dosya işlendikten sonra üretiliyor ve template expression bulamadığı değişkeni hata vermeden boş string'e çeviriyor. Hata almadığın için sorun ancak çıktıda boş bir alan görünce fark ediliyor.
Secret'ı environment'ta aramak. Secret değişkeni env: ile bağlamadan script'te okumaya çalışınca değer boş geliyor. Değişkeni kökte başka bir değişkene atamak da çözüm değil; bağlamanın yeri step'in kendisi.
Küçük Bir Deneme
★☆☆ set-version.sh'i yerelde farklı BUILD_SOURCEBRANCH değerleriyle çalıştır. refs/tags/v1.0.0 için hangi eki ürettiğine bak ve tag'ler için ayrı bir kural ekle.
★★☆ Pipeline'a Build.Reason değeri Schedule olduğunda çalışan bir step ekle ve koşulu succeeded()'i ezmeyecek biçimde yaz. Aynı koşulu bir de ${{ if }} ile yazmayı dene ve neden çalışmayacağını açıkla.
★★★ Pipeline'ı iki stage'e böl: Build ve Report. Report stage'indeki bir job, sürümü stageDependencies ile okusun. Ardından üçüncü bir stage ekleyip aynı değeri okumaya çalış ve hangi dependsOn eklemesinin gerektiğini bul.
Pipeline'ımız büyüdü ve Angular job'ı eklendiğinde aynı UseDotNet, restore ve test step'lerini başka repository'lerde de yazmaya başlayacağız. Bir sonraki yazıda bu tekrarı step, job ve stage template'leriyle ortak bir yere taşıyacak, extends template ile de her pipeline'ın uymak zorunda olduğu bir iskelet kuracağız.