Jenkins Shared Library: Ortak Pipeline Kodunu vars/ ve src/ ile Paylaşmak
Shared Library, tekrar eden pipeline kodunu ayrı bir repository'de vars/ step'leri ve src/ class'ları olarak tutar; pipeline'lar onu @Library('lib@v1.2.0') ile tag'e pin'leyerek yükler. dotnetCi ve angularCi step'leri, JCasC ile global library, pin'siz main'in riski, trusted global ve sandbox'taki folder library farkının gerçek çıktısı, JenkinsPipelineUnit testi ve ne zaman library yazmamak gerektiği.
Sürüm notu: Bu yazıdaki örnekler Jenkins 2.580.2 LTS (Long-Term Support), Pipeline: Groovy Libraries, JenkinsPipelineUnit 1.31, Gradle 9.8 ve Groovy 3.0 ile denenmiştir.
Önce Tahmin Et
Ortak bir Shared Library'yi on iki ekip kullanıyor ve hepsi Jenkinsfile'larında @Library('bookstore-lib') _ yazıyor, yani sürüm belirtmeden. Library'nin varsayılan sürümü main branch'i. Bir geliştirici cuma akşamı library'de küçük bir "temizlik" yapıp main'e merge ediyor. Pazartesi sabahı kaç pipeline etkilenmiş olur ve bunu fark etmek için ne olması gerekir?
Shared Library Nedir, Ne Zaman Gerekir?
Dokuzuncu yazının sonunda Bookstore'un Jenkinsfile'ı 150 satıra yaklaşmıştı: .NET build ve testi, Angular build ve testi, paketleme, deploy. Şirkette Bookstore gibi beş uygulama daha olduğunu düşün; her birinin Jenkinsfile'ında aynı dotnet test satırı, aynı coverage eşiği, aynı JUnit path'i. Biri xUnit'in rapor seçeneğini değiştirdiğinde altı repository'de altı PR (Pull Request) açılır. Shared Library bu ortak kodu ayrı bir Git repository'sinde tutup pipeline'lara bir step olarak sunmanın yolu.
Ama önce dürüst bir uyarı: iki pipeline için library yazmak erken soyutlamadır. Library'nin kendi sürümlemesi, testi, dokümantasyonu ve bakımı var; hata ayıklaması da Jenkinsfile'dan zordur, çünkü kod artık başka bir repository'de. Kural olarak aynı kod üç ya da daha fazla pipeline'da tekrarlanıyorsa ve gerçekten aynı şeyi yapıyorsa library'ye taşı. Tekrar eden şey birkaç shell komutuysa, onu bir script'e koymak çoğu zaman yeterli; dokuzuncu yazıdaki ci/deploy.sh bunun örneği.
Klasör Yapısı: vars/, src/, resources/
Bookstore'un library repository'si:
pipeline-library/
├── build.gradle
├── src/org/bookstore/Versioning.groovy
├── test/DotnetCiTest.groovy
└── vars/
├── angularCi.groovy
├── dotnetCi.groovy
├── dotnetCi.txt
└── jenkinsInfo.groovy
vars/ altındaki her dosya, adıyla çağrılan global bir step olur: vars/dotnetCi.groovy Jenkinsfile'da dotnetCi(...) olarak kullanılır, dosyadaki call method'u çalışır. Yanındaki .txt dosyası step'in yardım metni olarak Pipeline Syntax sayfasında görünür. src/ normal bir Groovy kaynak klasörüdür; buradaki class'ları import ile kullanırsın. resources/ ise script, şablon gibi kod olmayan dosyalar içindir ve libraryResource adımıyla okunur.
Sürümü branch adı ve build numarasından üreten küçük bir class:
package org.bookstore
class Versioning implements Serializable {
static String fromBuild(String branch, int buildNumber) {
String base = "1.4.${buildNumber}"
if (branch == null || branch == 'main') {
return base
}
String suffix = branch.replaceAll(/[^A-Za-z0-9]+/, '-').toLowerCase()
return "${base}-${suffix}"
}
}
Serializable önemli: üçüncü yazıdaki CPS (Continuation-Passing Style) kuralı library kodunda da geçerli, pipeline adımları arasında tutulan her object diske yazılabilmeli. .NET tarafının custom step'i:
import org.bookstore.Versioning
def call(Map config = [:]) {
String solution = config.solution ?: 'Bookstore.slnx'
double threshold = (config.coverageThreshold ?: 80) as double
String version = Versioning.fromBuild(env.BRANCH_NAME, env.BUILD_NUMBER as int)
sh "dotnet restore ${solution}"
sh "dotnet build ${solution} -c Release --no-restore -p:Version=${version}"
try {
sh "dotnet test --solution ${solution} -c Release --no-build --results-directory TestResults --report-xunit-junit --coverage --coverage-output-format cobertura"
} finally {
junit testResults: 'TestResults/**/*.junit.xml'
recordCoverage(tools: [[parser: 'COBERTURA', pattern: 'TestResults/**/*.cobertura.xml']],
sourceCodeRetention: 'NEVER',
qualityGates: [[threshold: threshold, metric: 'LINE', baseline: 'PROJECT', criticality: 'UNSTABLE']])
}
return version
}
Parametreleri bir Map ile almak, step'e sonradan yeni bir seçenek eklediğinde eski çağrıların bozulmamasını sağlıyor. Burada sh çift tırnaklı, çünkü yalnız solution adı ve sürüm gibi secret olmayan değerler interpolate ediliyor; beşinci yazıdaki kural secret'lar için. try/finally, testler kırılsa bile raporun kaydedilmesi için. angularCi aynı yapıda; dir(appDir) içinde npm ci, ng lint, ng test ve ng build çalıştırıyor.
Library'yi Tanımlamak ve Sürümü Pin'lemek
Global library'yi controller'a JCasC (Jenkins Configuration as Code) ile tanımladım:
unclassified:
globalLibraries:
libraries:
- name: "bookstore-lib"
defaultVersion: "v1.2.0"
implicit: false
allowVersionOverride: true
retriever:
modernSCM:
scm:
git:
remote: "http://jenkwork-gitea:3000/bookstore/pipeline-library.git"
credentialsId: "gitea-ci"
implicit: false, library'nin yalnız açıkça isteyen pipeline'lara yükleneceği anlamına geliyor; true olsaydı her pipeline'a otomatik yüklenirdi. Varsayılan sürümü bir branch değil bir tag yaptım. Jenkinsfile tarafında sürümü yine açıkça yazıyorum:
@Library('bookstore-lib@v1.2.0') _
pipeline {
agent none
options {
timeout(time: 30, unit: 'MINUTES')
timestamps()
}
stages {
stage('Build and Test') {
parallel {
stage('API') {
agent {
docker {
image 'mcr.microsoft.com/dotnet/sdk:10.0'
label 'docker'
args '-v bookstore-nuget:/nuget'
}
}
environment {
NUGET_PACKAGES = '/nuget/packages'
DOTNET_NOLOGO = '1'
}
steps {
script {
String version = dotnetCi(solution: 'Bookstore.slnx', coverageThreshold: 80)
echo "Built version ${version}"
}
}
}
stage('Web') {
agent {
docker {
image 'node:24-trixie'
label 'docker'
args '-v bookstore-npm:/npm'
}
}
environment {
npm_config_cache = '/npm'
}
steps {
angularCi(appDir: 'bookstore-web')
}
}
}
}
stage('Info') {
agent { label 'linux' }
steps {
jenkinsInfo()
}
}
}
}
Sondaki _ bir Groovy ayrıntısı: annotation'ın bir şeye bağlanması gerekiyor ve _ "burada import edilecek bir şey yok" demenin geleneksel yolu. Bir step'in dönüş değerini kullanmak için script bloğu gerekiyor; değer kullanılmıyorsa angularCi(...) gibi doğrudan steps içinde çağrılabilir. Bu Jenkinsfile'ı feature/shared-library branch'ine push ettim; build log'undan:
Loading library bookstore-lib@v1.2.0
Attempting to resolve v1.2.0 from remote references...
Found match: refs/tags/v1.2.0 revision beb3a44f4c4e454309e0c04528e711c1639978d8
Resolved tag v1.2.0 revision beb3a44f4c4e454309e0c04528e711c1639978d8
Checking out Revision beb3a44f4c4e454309e0c04528e711c1639978d8 (v1.2.0)
[2026-11-08T04:54:19.768Z] + dotnet restore Bookstore.slnx
[2026-11-08T04:54:22.740Z] + dotnet build Bookstore.slnx -c Release --no-restore -p:Version=1.4.1-feature-shared-library
[2026-11-08T04:54:23.222Z] + npx ng lint
[2026-11-08T04:54:24.927Z] + dotnet test --solution Bookstore.slnx -c Release --no-build --results-directory TestResults --report-xunit-junit --coverage --coverage-output-format cobertura
[2026-11-08T04:54:26.881Z] [Coverage] - [Whole project - Line Coverage]: ≪Success≫ - (Actual value: 87.50%, Quality gate: 80.00)
[2026-11-08T04:54:26.887Z] Built version 1.4.1-feature-shared-library
[2026-11-08T04:54:29.548Z] Running on Jenkins 2.580.2
Finished: SUCCESS
Jenkins tag'i bir commit'e çözdü ve library'yi o commit'ten yükledi. Sürüm de Versioning class'ından, branch adına göre geldi: 1.4.1-feature-shared-library. Bu build'den sonra library'nin main branch'ine test ekleyen yeni bir commit attım; tag yerinde durduğu için Bookstore'un pipeline'ı bundan etkilenmedi.
Baştaki sorunun cevabı burada: @Library('bookstore-lib') _ ve varsayılan sürüm main ise, library'nin main'ine giren her commit, on iki ekibin bir sonraki build'inde aynı anda devreye girer. Hata varsa pazartesi sabahı on iki pipeline kırmızıdır ve hiçbirinin Jenkinsfile'ında değişiklik yoktur. Library'yi uygulama gibi sürümle: semantic version'lı tag'ler, changelog, ve pipeline'larda tag'e pin'leme. Yeni sürüme geçmek, her ekibin kendi PR'ında @v1.2.0'ı @v1.3.0 yapması olsun; kırılırsa o PR'da kırılır.
Trusted ve Untrusted Library: Sandbox
Global library'ler (Jenkins'in yönetim ayarlarında tanımlanan) "trusted" kabul edilir: Groovy sandbox'ının dışında çalışır, Jenkins'in iç API'lerine (Application Programming Interface) erişebilir. jenkinsInfo step'i tam olarak bunu yapıyor:
def call() {
echo "Running on Jenkins ${jenkins.model.Jenkins.get().getVersion()}"
}
Global library'den çağrıldığında yukarıdaki log'daki gibi Running on Jenkins 2.580.2 yazdı. Aynı repository'yi team-web folder'ında bir folder library'si (team-web-lib) olarak tanımladım ve aynı step'i oradan çağırdım:
Loading library team-web-lib@v1.2.0
Checking out Revision beb3a44f4c4e454309e0c04528e711c1639978d8 (v1.2.0)
Running on ssh-agent-1 in /home/jenkins/agent/workspace/team-web/lib-check
Scripts not permitted to use staticMethod jenkins.model.Jenkins get. Administrators can decide whether to approve or reject this signature.
org.jenkinsci.plugins.scriptsecurity.sandbox.RejectedAccessException: Scripts not permitted to use staticMethod jenkins.model.Jenkins get
Finished: FAILURE
Folder library'si "untrusted": folder'ı yönetebilen herkes onu değiştirebileceği için Jenkinsfile'larla aynı sandbox'ta çalışır. Bu ayrım bir güvenlik sınırı. Trusted library'ye commit atabilen biri controller üzerinde istediği kodu çalıştırabilir; bu yüzden global library repository'sine yazma yetkisi, Jenkins admin yetkisi kadar dikkatle verilmeli ve değişiklikler review'dan geçmeli. "Script approval" ile sandbox'tan tek tek imza onaylamak mümkün, ama her onay o imzayı bütün pipeline'lara açar; onaylamadan önce iki kez düşün.
Library'yi Test Etmek: JenkinsPipelineUnit
Library kodu, kırıldığında herkesi etkilediği için test edilmeyi en çok hak eden pipeline kodu. JenkinsPipelineUnit, vars/ script'lerini Jenkins olmadan yükleyip adım çağrılarını kaydeden bir test framework'ü. Gradle yapılandırması:
plugins {
id 'groovy'
}
repositories {
mavenCentral()
maven { url = 'https://repo.jenkins-ci.org/releases/' }
}
sourceSets {
main { groovy { srcDirs = ['src'] } }
test { groovy { srcDirs = ['test'] } }
}
dependencies {
implementation 'org.codehaus.groovy:groovy-all:3.0.25'
testImplementation 'com.lesfurets:jenkins-pipeline-unit:1.31'
testImplementation 'junit:junit:4.13.2'
}
test {
testLogging {
events 'passed', 'failed'
exceptionFormat = 'full'
}
}
JenkinsPipelineUnit 1.2'den beri Maven Central'da değil Jenkins'in kendi repository'sinde yayımlanıyor; bu yüzden ikinci maven satırı var. Test:
import com.lesfurets.jenkins.unit.BasePipelineTest
import org.bookstore.Versioning
import org.junit.Before
import org.junit.Test
import static org.junit.Assert.assertEquals
import static org.junit.Assert.assertTrue
class DotnetCiTest extends BasePipelineTest {
@Override
@Before
void setUp() {
super.setUp()
helper.registerAllowedMethod('junit', [Map]) { }
helper.registerAllowedMethod('recordCoverage', [Map]) { }
binding.setVariable('env', [BRANCH_NAME: 'main', BUILD_NUMBER: '42'])
}
@Test
void restoresBuildsAndTestsInOrder() {
def dotnetCi = loadScript('vars/dotnetCi.groovy')
String version = dotnetCi.call(solution: 'Bookstore.slnx')
assertEquals('1.4.42', version)
List<String> commands = helper.callStack.findAll { it.methodName == 'sh' }.collect { it.argsToString() }
assertEquals(3, commands.size())
assertTrue(commands[0].startsWith('dotnet restore Bookstore.slnx'))
assertTrue(commands[2].contains('--report-xunit-junit'))
}
@Test
void featureBranchesGetASuffix() {
assertEquals('1.4.7-feature-stock-badge', Versioning.fromBuild('feature/stock-badge', 7))
}
}
İlk çalıştırmada junit satırı yoktu ve test şöyle kırıldı:
groovy.lang.MissingMethodException: No signature of method: dotnetCi.junit() is applicable for argument types: (LinkedHashMap) values: [[testResults:TestResults/**/*.junit.xml]]
JenkinsPipelineUnit sık kullanılan adımları hazır kaydediyor, ama her imzayı değil; junit'i map parametresiyle çağırdığım için o imzayı ayrıca kaydetmem gerekti. Düzelttikten sonra, Gradle'ı bir container'da çalıştırarak:
$ docker run --rm -u "$(id -u):$(id -g)" -e GRADLE_USER_HOME=/w/.gradle-home -v "$PWD":/w -w /w gradle:9.8.2-jdk21 gradle test --no-daemon --console=plain
> Task :test
DotnetCiTest > restoresBuildsAndTestsInOrder PASSED
DotnetCiTest > featureBranchesGetASuffix PASSED
BUILD SUCCESSFUL in 3s
Bu testler gerçek bir Jenkins'i taklit etmiyor; yalnız library'nin hangi adımları hangi sırayla ve hangi parametrelerle çağırdığını doğruluyor. Yine de "birisi dotnet test satırından --report-xunit-junit'i sildi" gibi hataları tag atılmadan yakalamaya yetiyor. Library repository'sinin kendi pipeline'ı bu testleri her PR'da koşmalı.
Üretimde Dikkat
Library'yi bir ürün gibi yönet: README, her step için .txt yardım dosyası, changelog ve semantic versioning. Kırıcı bir değişiklik major sürüm demek.
Step'leri küçük ve birleştirilebilir tut. Bütün pipeline'ı tek bir bookstorePipeline() çağrısına saklayan "her şeyi yapan" library'ler ilk başta çekici görünür, ama ekipler bir gün bir stage eklemek istediğinde library'ye parametre üstüne parametre eklenir.
Library'nin kodu da controller'da CPS ile yorumlanıyor. Ağır işi orada yapma; büyük JSON (JavaScript Object Notation) dosyalarını parse etmek gibi işler yine agent'ta, sh içinde yapılmalı.
Sık Düşülen Tuzaklar
Library'yi pin'siz main'den çekmek. Yukarıda anlattığım cuma akşamı senaryosu.
İki pipeline için library yazmak. Bakımı, kopyalanan yirmi satırdan pahalıya gelir.
Global library'ye herkese yazma yetkisi vermek. Trusted kod sandbox dışında çalışır; library repository'si Jenkins admin yetkisiyle eşdeğerdir.
src/ class'larında serializable olmayan state tutmak. Class'ın field'ları adımlar arasında diske yazılır; Serializable olmayan bir field NotSerializableException demek.
Library'yi test etmemek. Kırıldığında bütün ekipler aynı anda etkilenir; en az birkaç JenkinsPipelineUnit testi tag atmadan önce koşmalı.
Küçük Bir Deneme
★☆☆ Kendi library'nde vars/ altında tek satırlık bir sayHello step'i yaz, global library olarak tanımla ve bir Jenkinsfile'dan çağır. .txt dosyasının Pipeline Syntax sayfasında nasıl göründüğüne bak.
★★☆ Library'yi @main ile çağıran bir pipeline ve @v1.2.0 ile çağıran bir pipeline oluştur. Library'nin main'ine bir değişiklik push et ve iki pipeline'ın bir sonraki build'lerini karşılaştır.
★★★ angularCi için bir JenkinsPipelineUnit testi yaz: dir adımının appDir parametresiyle çağrıldığını ve ng test'in --watch=false içerdiğini doğrulasın. Sonra testi bilerek kıracak bir değişiklik yap.
Pipeline kodu artık repository'lerde ve sürümlü bir library'de. Ama controller'ın kendisi, yani plugin'ler, credential'lar, agent tanımları ve library ayarı hâlâ tek bir sunucuda yaşıyor. Bir sonraki yazıda Jenkins'in kendisini kod olarak yönetiyoruz: JCasC, plugins.txt, yedekleme ve upgrade.