Mert Özen Her Satırda Daha İleri
DevOps Jenkins — #03

Jenkinsfile ile İlk Pipeline: Declarative Syntax, stages, steps, post ve environment

Mert Özen 8 Kas 2026 10 dk 4 görüntülenme
Jenkinsfile ile İlk Pipeline: Declarative Syntax, stages, steps, post ve environment

Jenkinsfile, pipeline'ı kodla aynı repository'de tarif eder; Declarative syntax'ta agent, stages ve steps iskeletin kendisi, environment, options, parameters, triggers ve post ise davranışı belirler. Linter çıktısı, tırnak kuralı, ilk build'de shell'e boş gelen parametre, when ile atlanan stage, Snippet Generator ve Replay, Scripted Pipeline ve NotSerializableException ile @NonCPS.

Sürüm notu: Bu yazıdaki örnekler Jenkins 2.580.2 LTS (Long-Term Support), Pipeline: Declarative (workflow-aggregator 608) ve Gitea 28 ile denenmiştir.

Önce Tahmin Et

Aşağıdaki pipeline'ı yeni bir job olarak oluşturup ilk kez "Build" butonuyla çalıştırıyorsun. Konsola iki satır düşecek: biri Groovy'nin echo'sundan, biri shell'den. İkisi de "Release" mi yazar?

pipeline {
    agent { label 'linux' }
    parameters {
        choice(name: 'BUILD_CONFIGURATION', choices: ['Release', 'Debug'], description: 'dotnet build configuration')
    }
    stages {
        stage('Show') {
            steps {
                echo "params: ${params.BUILD_CONFIGURATION}"
                sh 'echo "shell: $BUILD_CONFIGURATION"'
            }
        }
    }
}

Jenkinsfile Repository'de Yaşar

Pipeline'ın tarifi uygulamanın kendisiyle aynı repository'de, kök klasörde Jenkinsfile adlı bir dosyada durur. Bookstore repository'sine koyduğum ilk sürüm:

pipeline {
    agent { label 'linux' }

    options {
        timeout(time: 15, unit: 'MINUTES')
        timestamps()
        buildDiscarder(logRotator(numToKeepStr: '20'))
        disableConcurrentBuilds()
    }

    environment {
        APP_NAME = 'Bookstore.Api'
        VERSION = "1.4.${BUILD_NUMBER}"
    }

    stages {
        stage('Info') {
            steps {
                echo "Building ${APP_NAME} ${VERSION} from ${env.BRANCH_NAME}"
                sh 'git log -1 --format="%h %s"'
            }
        }
    }

    post {
        always {
            echo "Result: ${currentBuild.currentResult}"
        }
    }
}

Jenkins tarafında bu repository'yi izleyen bir multibranch pipeline job'u var (onu altıncı yazıda ayrıntılı kuracağız). Push'tan sonraki build'in log'undan seçtiğim satırlar:

Obtained Jenkinsfile from 50a397b45b74956d7f0fb0ac21d1d168deefe814
Running on docker-agent-1 in /home/jenkins/agent/workspace/bookstore_main
using credential gitea-ci
Cloning repository http://jenkwork-gitea:3000/bookstore/bookstore.git
Checking out Revision 50a397b45b74956d7f0fb0ac21d1d168deefe814 (main)
Commit message: "Add Bookstore API, web app and first Jenkinsfile"
Timeout set to expire in 15 min
[2026-11-08T04:03:20.255Z] Building Bookstore.Api 1.4.1 from main
[2026-11-08T04:03:20.535Z] + git log -1 --format=%h %s
[2026-11-08T04:03:20.535Z] 50a397b Add Bookstore API, web app and first Jenkinsfile
[2026-11-08T04:03:20.561Z] Result: SUCCESS
Finished: SUCCESS

Dikkat edersen ilk satır "Jenkinsfile'ı şu commit'ten aldım" diyor. Jenkins her build'de Jenkinsfile'ı o build'in commit'inden okur; yani pipeline'daki bir değişiklik de normal bir kod değişikliği gibi pull request'ten geçer ve geri alınabilir. Declarative pipeline'da ayrıca checkout scm yazmana gerek yok: agent'a geçildiği anda Jenkins aynı commit'i workspace'e checkout eder.

İskelet: pipeline, agent, stages, steps

Declarative Pipeline'ın kuralları sıkıdır ve bu iyi bir şeydir. En dışta tek bir pipeline bloğu olur. agent pipeline'ın nerede koşacağını söyler; label 'linux' "bu label'a sahip herhangi bir agent" demek, none ise "üst seviyede agent alma, her stage kendisi seçsin" demek. stages içinde bir veya daha fazla stage bulunur; her stage'in adı Jenkins arayüzünde bir kutu olarak görünür ve süresi ayrı ölçülür. Asıl iş steps içindeki adımlardadır: sh, echo, junit, archiveArtifacts gibi.

Bu kurallara uymayan bir Jenkinsfile çalıştırılmadan reddedilir. steps yerine stpes yazdığım bir dosyayı controller'ın linter endpoint'ine gönderdim:

curl -s -u "admin:$JENKINS_ADMIN_PASSWORD" -H "$CRUMB" -X POST \
  -F "jenkinsfile=<Jenkinsfile" http://localhost:18400/pipeline-model-converter/validate
Errors encountered validating Jenkinsfile:
WorkflowScript: 4: Unknown stage section "stpes". Starting with version 0.5, steps in a stage must be in a ‘steps’ block. @ line 4, column 9.
           stage('Build') {
           ^

WorkflowScript: 4: Expected one of "steps", "stages", or "parallel" for stage "Build" @ line 4, column 9.
           stage('Build') {
           ^

Doğru bir dosya için aynı endpoint yalnızca Jenkinsfile successfully validated. döner. Bu küçük curl komutunu bir git hook'una ya da editörüne bağlarsan "push et, build'in kırılmasını bekle, düzelt" döngüsünden kurtulursun.

Pipeline'ın Ayar Blokları

İskeletin etrafında, pipeline'ın nasıl davranacağını söyleyen beş blok var. Hepsi isteğe bağlı, ama gerçek bir pipeline'da çoğunu kullanırsın.

environment ve Tırnak Meselesi

environment bloğundaki değişkenler pipeline boyunca hem Groovy'den (env.VERSION veya kısaca VERSION) hem de sh içinden ($VERSION) okunur. Burada bilmen gereken tek ama önemli kural tırnaklarla ilgili. Groovy'de çift tırnaklı string interpolation yapar: "1.4.${BUILD_NUMBER}" Groovy tarafında, build başlarken 1.4.1'e dönüşür. Tek tırnaklı string olduğu gibi kalır: sh 'git log -1' içindeki her şeyi shell yorumlar. sh "echo $VERSION" yazdığında değişkeni Groovy doldurur, sh 'echo $VERSION' yazdığında shell doldurur. Normal değişkenlerde ikisi aynı sonucu verir; secret'larda ise fark hayati. Beşinci yazının konusu tam olarak bu.

options: Build'in Davranışı

options pipeline'ın kendisine uygulanan ayarlardır. Bizim Jenkinsfile'daki dördü neredeyse her pipeline'da olmalı:

  • timeout(time: 15, unit: 'MINUTES'): takılan bir build sonsuza kadar executor tutmasın. Log'daki Timeout set to expire in 15 min satırı bu.
  • timestamps(): her satırın başına zaman damgası. Yavaş adımı bulmanın en ucuz yolu.
  • buildDiscarder(logRotator(numToKeepStr: '20')): son 20 build'i tut, gerisini sil. Controller diskini korur.
  • disableConcurrentBuilds(): aynı job'un iki build'i aynı anda koşmasın. Deploy eden pipeline'larda özellikle önemli.

Birinci yazıda gördüğümüz önemli bir ayrıntı: üst seviyedeki timeout, agent bulunduktan sonra başlar. Queue'da bekleme süresi bu süreye dahil değildir.

parameters, when ve İlk Build Tuzağı

parameters build'i başlatan kişiye seçenek sunar, when ise bir stage'i koşula bağlar. Yazının başındaki pipeline'ın gerçek çıktısı şöyleydi. İlk build:

params: Release
+ echo shell:
shell: 

İkinci build, bu kez "Build with Parameters" ile varsayılan değer seçilerek:

Started by user admin
params: Release
+ echo shell: Release
shell: Release

Sebep şu: parameters bloğu job'un parametre tanımını ancak pipeline bir kez çalıştıktan sonra kaydeder. İlk build parametresiz başlatılmış bir build'dir; params object'i declarative tarafından varsayılan değerlerle doldurulduğu için Groovy "Release" görür, ama build'e bağlı gerçek bir parametre olmadığından environment variable da yoktur ve shell boş string görür. Tek tırnaklı sh içinde parametreye güveniyorsan ilk build'de (ve parametre listesini değiştirdiğin her build'den sonraki ilk çalıştırmada) bu sürprizi yaşarsın. Groovy tarafında params.X kullanmak ya da değeri environment bloğunda BUILD_CONFIGURATION = "${params.BUILD_CONFIGURATION}" diye açıkça aktarmak bu boşluğu kapatır.

when tarafında, RUN_TESTS=false ile başlattığım build'de stage atlandı ve log bunu açıkça söyledi:

stage('Test') {
    when {
        expression { params.RUN_TESTS }
    }
    steps {
        sh 'echo "pretend: dotnet test -c $BUILD_CONFIGURATION"'
    }
}
[2026-11-08T03:40:22.206Z] Building Bookstore.Api 1.4.3 (Debug)
Stage "Test" skipped due to when conditional
[2026-11-08T03:40:22.578Z] Result so far: SUCCESS
[2026-11-08T03:40:22.584Z] Build is green

when içinde branch 'main', changeRequest(), tag 'v*', environment name: 'DEPLOY', value: 'true' gibi hazır koşullar ve bunları birleştiren allOf, anyOf, not var. Branch ve PR (Pull Request) koşullarını multibranch yazısında kullanacağız.

triggers ve post

triggers build'i bir olaya bağlar. Gece build'i için:

triggers {
    cron('H 2 * * 1-5')
}

H "hash" demek: Jenkins dakikayı job adından türetir, böylece yüz job'un hepsi saat 02:00:00'da aynı anda başlamaz. Bu bloğu içeren pipeline bir kez çalıştıktan sonra job'un configuration'ında <spec>H 2 * * 1-5</spec> olarak kayıtlı bir TimerTrigger belirdi; yani triggers da tıpkı parameters gibi ilk çalıştırmada kaydoluyor. pollSCM de aynı yerde yazılır, ama Git sunucusunu dakikada bir sorgulamak yerine webhook kullanmak hem daha hızlı hem daha ucuz.

post build bittikten sonra sonuca göre çalışır. always, success ve failure en sık kullanılanlar; unstable (test başarısız ama build tamamlandı), aborted, fixed (önceki kırmızıydı, bu yeşil), regression ve en sonda çalışan cleanup da var. Bildirim göndermek, test raporu yayımlamak ve workspace'i temizlemek post'un işi.

Araçlar: Snippet Generator, Replay ve Linter

Hiçbir adımın parametrelerini ezberlemene gerek yok. Her pipeline job'unun sayfasında "Pipeline Syntax" linki var; oradaki Snippet Generator bir adımı formdan doldurup Groovy karşılığını üretir, "Declarative Directive Generator" da options, when, parameters gibi blokları üretir. Kurulu plugin'lere göre değiştiği için senin Jenkins'inde geçerli olan syntax'ı gösterir; internetteki eski örneklerden daha güvenilirdir.

Replay, bitmiş bir build'in Jenkinsfile'ını tarayıcıda düzenleyip commit atmadan yeniden çalıştırmanı sağlar. Küçük bir denemeyi hızlıca doğrulamak için çok kullanışlı, ama kalıcı değişiklik değil: düzeltmeyi sonra mutlaka repository'ye yazmalısın. Linter'ı yukarıda gördün.

Scripted Pipeline ve script {} Kaçış Kapısı

Eski Jenkinsfile'larda node ile başlayan Scripted Pipeline görürsün:

node('linux') {
    stage('Info') {
        sh 'echo "Scripted on $NODE_NAME"'
    }
    if (env.BRANCH_NAME == null) {
        echo 'Not a multibranch build'
    }
}
Running on docker-agent-1 in /home/jenkins/agent/workspace/scripted
+ echo Scripted on docker-agent-1
Scripted on docker-agent-1
Not a multibranch build

Düz Groovy olduğu için if, döngü, try/catch serbest. Bedeli: linter yok, options, post, when gibi hazır yapılar yok, her şeyi elle yazarsın. Declarative içinde Groovy gerektiğinde ise script { } bloğu açarsın. İyi bir kural: script bloğu birkaç satırı geçiyorsa o kod ya bir shell script'e ya da onuncu yazıda göreceğimiz Shared Library'ye taşınmalı.

Groovy CPS Tuzakları

Jenkins pipeline'daki Groovy kodunu doğrudan çalıştırmaz; CPS (Continuation-Passing Style) adlı bir dönüşümden geçirir. Amaç, controller yeniden başlasa bile pipeline'ın kaldığı yerden devam edebilmesi. Bunun için Jenkins her adımda (her sh, her echo) programın durumunu diske yazar. Diske yazılamayan bir object o anda bellekteyse pipeline patlar:

script {
    def matcher = ('Bookstore.Api-1.4.7' =~ /(\d+)\.(\d+)\.(\d+)/)
    sh 'echo checkpoint'
    echo "major: ${matcher[0][1]}"
}
an exception which occurred:
	in field com.cloudbees.groovy.cps.impl.BlockScopeEnv.locals
	in object com.cloudbees.groovy.cps.impl.BlockScopeEnv@79acd7ca
	...
Caused: java.io.NotSerializableException: java.util.regex.Matcher
Finished: FAILURE

java.util.regex.Matcher serializable değil ve sh adımına girilirken hâlâ local variable olarak duruyor. Çözüm, serializable olmayan işi @NonCPS işaretli bir method'a almak ve dışarıya yalnız düz bir değer (string, sayı) döndürmek:

@NonCPS
String majorVersion(String text) {
    def matcher = (text =~ /(\d+)\.(\d+)\.(\d+)/)
    return matcher ? matcher[0][1] : 'unknown'
}

pipeline {
    agent { label 'linux' }
    stages {
        stage('Version') {
            steps {
                script {
                    String major = majorVersion('Bookstore.Api-1.4.7')
                    sh 'echo checkpoint'
                    echo "major: ${major}"
                }
            }
        }
    }
}
+ echo checkpoint
checkpoint
major: 1
Finished: SUCCESS

@NonCPS method'u normal Groovy gibi bir seferde çalışır ve durumu kaydedilmez. Bu yüzden içinden sh, echo gibi pipeline adımları çağrılmaz; çağırırsan beklenmedik davranışlar görürsün. Bir de unutma: bütün bu Groovy, agent'ta değil controller'ın JVM'inde (Java Virtual Machine) çalışıyor. 50 MB'lık bir JSON (JavaScript Object Notation) dosyasını readFile ile okuyup Groovy'de parse etmek bütün controller'ı yavaşlatır. Ağır işi agent'ta yap: sh 'jq ... report.json' ya da küçük bir script.

Üretimde Dikkat

Her pipeline'a bir timeout koy. Testte asılı kalan bir process, executor'ı saatlerce tutar ve arkadaki bütün build'ler bekler.

buildDiscarder olmadan her build'in log'u ve kaydı controller diskinde sonsuza kadar kalır. Yüzlerce branch'li bir multibranch projede bu birkaç ayda gigabaytlar eder.

Pipeline'ı mümkün olduğunca ince tut. Jenkinsfile bir orkestra şefi gibi olmalı: hangi araç, hangi sırayla, hangi sonuçla. Asıl iş (dotnet test, npm ci, ng build) aynı komutlarla geliştiricinin makinesinde de çalışabilmeli. Bu, hem CPS yükünü azaltır hem de "Jenkins'te kırılıyor ama bende çalışıyor" tartışmalarını kısaltır.

Sık Düşülen Tuzaklar

İlk build'deki parametre boşluğu. Yukarıda gördüğün gibi yeni bir job'un ilk build'inde parametreler environment variable olarak gelmez. Deploy hedefi gibi kritik bir değeri parametreden okuyorsan varsayılanı boş bırakma ve değeri doğrula.

Groovy'de secret'ı çift tırnakla interpolate etmek. sh "curl -H 'Authorization: $TOKEN' ..." secret'ı process listesine ve log'a sızdırabilir. Bu tuzağı credentials yazısında gerçek çıktısıyla göreceğiz.

Replay ile düzeltip repository'ye yazmayı unutmak. Replay'deki değişiklik o build'le birlikte kaybolur; bir sonraki push eski Jenkinsfile ile tekrar kırılır.

Declarative'i script bloklarıyla Scripted'a çevirmek. Yarısı script { } olan bir Jenkinsfile, Declarative'in getirdiği doğrulamayı ve okunabilirliği kaybeder.

Serializable olmayan object'leri adımlar arasında taşımak. Matcher, JsonSlurper'ın lazy map'i, açık bir stream: hepsi NotSerializableException sebebi.

Küçük Bir Deneme

★☆☆ Kendi Jenkinsfile'ına options bloğundaki dört ayarı ekle ve log'da zaman damgalarını ve Timeout set to expire satırını gör. Sonra stpes gibi bilerek bir yazım hatası yapıp dosyayı linter endpoint'ine gönder.

★★☆ Yazının başındaki parametre örneğini yeni bir job'da çalıştır, ilk build'de shell'in boş string gördüğünü doğrula. Sonra environment bloğuyla değeri açıkça aktararak ilk build'de de doğru değeri gördüğünü göster.

★★★ CPS örneğini kendi Jenkins'inde patlat, sonra iki farklı şekilde düzelt: bir kez @NonCPS method'uyla, bir kez de regex işini sh(script: '...', returnStdout: true) ile shell'e taşıyarak. Hangisi daha okunur, hangisi controller'ı daha az yorar?

Şimdiye kadar bütün build'ler label 'linux' ile "herhangi bir Linux agent'ında" koştu. Bir sonraki yazıda agent'ların kendisine bakıyoruz: SSH (Secure Shell) agent, WebSocket ile bağlanan inbound agent, label expression'lar ve build'i mcr.microsoft.com/dotnet/sdk:10.0 container'ının içinde koşturmak.