Questions
Teams need automated CI for all branches with different deployment targets. Implement multi-branch pipelines.
The Scenario
Your team has grown and adopted a branching strategy:
main- Production deploymentsdevelop- Staging deploymentsfeature/*- Dev environment + PR checkshotfix/*- Fast-track to production
Currently, you manually create a new job for each branch. With 50 active branches across 20 repositories, this is unsustainable. Teams want:
- Automatic pipeline creation for new branches
- Different behaviors per branch type
- PR status checks in GitHub
- Automatic cleanup when branches are deleted
The Challenge
Implement a multi-branch pipeline architecture that automatically manages CI/CD for all branches with appropriate deployment targets and integrations.
A junior engineer might create separate Jenkinsfiles per branch, manually create jobs for each feature branch, or use a single job with complex conditional logic. These approaches don't scale, create maintenance nightmares, and are error-prone.
A senior engineer implements Multi-branch Pipeline with Organization Folders, uses a single Jenkinsfile with branch-aware logic, integrates with GitHub for automatic discovery and PR status updates, and implements proper environment promotion based on branch patterns.
Step 1: Create Multi-branch Pipeline Job
// Jenkinsfile in repository root
pipeline {
agent any
options {
buildDiscarder(logRotator(
numToKeepStr: env.BRANCH_NAME == 'main' ? '50' : '10'
))
timestamps()
timeout(time: 60, unit: 'MINUTES')
}
environment {
APP_NAME = 'my-application'
REGISTRY = 'registry.company.com'
// Set based on branch
DEPLOY_ENV = getBranchEnvironment()
}
stages {
stage('Checkout') {
steps {
checkout scm
script {
env.GIT_COMMIT_SHORT = sh(
script: 'git rev-parse --short HEAD',
returnStdout: true
).trim()
env.IMAGE_TAG = "${env.GIT_COMMIT_SHORT}-${env.BUILD_NUMBER}"
}
}
}
stage('Build') {
steps {
sh 'npm ci'
sh 'npm run build'
}
}
stage('Test') {
parallel {
stage('Unit Tests') {
steps {
sh 'npm run test:unit'
}
}
stage('Lint') {
steps {
sh 'npm run lint'
}
}
}
}
stage('Security Scan') {
when {
anyOf {
branch 'main'
branch 'develop'
changeRequest() // PRs
}
}
steps {
sh 'npm audit --production'
sh 'trivy fs --exit-code 1 --severity HIGH,CRITICAL .'
}
}
stage('Build Docker Image') {
when {
anyOf {
branch 'main'
branch 'develop'
branch pattern: 'release/*', comparator: 'GLOB'
}
}
steps {
sh """
docker build -t ${REGISTRY}/${APP_NAME}:${IMAGE_TAG} .
docker push ${REGISTRY}/${APP_NAME}:${IMAGE_TAG}
"""
}
}
stage('Deploy to Dev') {
when {
branch pattern: 'feature/*', comparator: 'GLOB'
}
steps {
deployTo(environment: 'dev', tag: env.IMAGE_TAG)
}
}
stage('Deploy to Staging') {
when {
branch 'develop'
}
steps {
deployTo(environment: 'staging', tag: env.IMAGE_TAG)
}
}
stage('Deploy to Production') {
when {
branch 'main'
}
steps {
script {
// Require manual approval for production
timeout(time: 24, unit: 'HOURS') {
input message: 'Deploy to Production?',
ok: 'Deploy',
submitter: 'release-managers'
}
}
deployTo(environment: 'production', tag: env.IMAGE_TAG)
}
}
}
post {
always {
junit allowEmptyResults: true, testResults: '**/test-results/*.xml'
}
success {
script {
if (env.CHANGE_ID) { // It's a PR
pullRequest.comment("Build passed! Image: `${REGISTRY}/${APP_NAME}:${IMAGE_TAG}`")
}
}
}
failure {
script {
if (env.BRANCH_NAME == 'main' || env.BRANCH_NAME == 'develop') {
slackSend(
channel: '#build-failures',
color: 'danger',
message: "Build failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}"
)
}
}
}
}
}
def getBranchEnvironment() {
if (env.BRANCH_NAME == 'main') return 'production'
if (env.BRANCH_NAME == 'develop') return 'staging'
if (env.BRANCH_NAME?.startsWith('release/')) return 'staging'
if (env.BRANCH_NAME?.startsWith('feature/')) return 'dev'
if (env.BRANCH_NAME?.startsWith('hotfix/')) return 'production'
return 'dev'
}
def deployTo(Map args) {
def env = args.environment
def tag = args.tag
withCredentials([file(credentialsId: "kubeconfig-${env}", variable: 'KUBECONFIG')]) {
sh """
kubectl set image deployment/${APP_NAME} \
app=${REGISTRY}/${APP_NAME}:${tag} \
-n ${APP_NAME}-${env}
kubectl rollout status deployment/${APP_NAME} \
-n ${APP_NAME}-${env} \
--timeout=300s
"""
}
}Step 2: Configure Organization Folder
// JCasC configuration for GitHub Organization scanning
jenkins:
jobs:
- script: >
organizationFolder('my-org') {
description('All repositories in my-org')
displayName('My Organization')
organizations {
github {
repoOwner('my-org')
credentialsId('github-app')
traits {
gitHubBranchDiscovery {
strategyId(1) // Exclude branches also filed as PRs
}
gitHubPullRequestDiscovery {
strategyId(1) // Merge PR with target branch
}
gitHubForkDiscovery {
strategyId(1)
trust {
gitHubTrustPermissions()
}
}
}
}
}
projectFactories {
workflowMultiBranchProjectFactory {
scriptPath('Jenkinsfile')
}
}
buildStrategies {
buildRegularBranches()
buildChangeRequests {
ignoreTargetOnlyChanges(false)
}
buildTags {
atLeastDays('-1')
atMostDays('7')
}
}
orphanedItemStrategy {
discardOldItems {
daysToKeep(7)
numToKeep(20)
}
}
triggers {
periodicFolderTrigger {
interval('1h')
}
}
}Step 3: GitHub Integration for PR Status
// Enhanced Jenkinsfile with GitHub status updates
pipeline {
agent any
options {
// Report build status to GitHub
githubProjectProperty(projectUrlStr: 'https://github.com/my-org/my-app')
}
stages {
stage('PR Validation') {
when {
changeRequest()
}
steps {
script {
// Validate PR requirements
def prInfo = pullRequest
// Check PR size
def changedFiles = prInfo.changedFiles
if (changedFiles > 50) {
echo "WARNING: Large PR with ${changedFiles} files"
}
// Check for required labels
def labels = prInfo.labels.collect { it.toLowerCase() }
if (!labels.any { it in ['bug', 'feature', 'enhancement', 'docs'] }) {
error 'PR must have a type label (bug, feature, enhancement, or docs)'
}
// Add reviewer suggestions based on files changed
if (prInfo.files.any { it.path.startsWith('security/') }) {
prInfo.addLabel('needs-security-review')
prInfo.comment('@security-team - Please review security changes')
}
}
}
}
stage('Build & Test') {
steps {
// GitHub sees each stage as a separate check
script {
setBuildStatus('pending', 'Build started')
}
sh 'npm ci && npm run build && npm test'
script {
setBuildStatus('success', 'Build passed')
}
}
}
stage('Integration Tests') {
when {
anyOf {
branch 'main'
branch 'develop'
changeRequest target: 'main'
changeRequest target: 'develop'
}
}
steps {
sh 'npm run test:integration'
}
}
}
post {
success {
script {
if (env.CHANGE_ID) {
// Update PR with success status
setBuildStatus('success', 'All checks passed')
// Add deployment preview URL for feature branches
def previewUrl = "https://${env.CHANGE_ID}.preview.company.com"
pullRequest.comment("""
Build successful!
**Preview URL:** ${previewUrl}
**Image Tag:** `${env.IMAGE_TAG}`
""")
}
}
}
failure {
script {
if (env.CHANGE_ID) {
setBuildStatus('failure', 'Build failed')
}
}
}
}
}
def setBuildStatus(String state, String description) {
step([
$class: 'GitHubCommitStatusSetter',
reposSource: [$class: 'ManuallyEnteredRepositorySource', url: env.GIT_URL],
commitShaSource: [$class: 'ManuallyEnteredShaSource', sha: env.GIT_COMMIT],
contextSource: [$class: 'ManuallyEnteredCommitContextSource', context: 'ci/jenkins'],
statusResultSource: [$class: 'ConditionalStatusResultSource', results: [
[$class: 'AnyBuildResult', state: state, message: description]
]]
])
}Step 4: Branch-Specific Behaviors
// More complex branch logic
pipeline {
agent any
stages {
stage('Initialize') {
steps {
script {
// Determine build configuration based on branch
env.RUN_E2E = shouldRunE2E()
env.DEPLOY_ENABLED = shouldDeploy()
env.SONAR_ENABLED = shouldRunSonar()
}
}
}
stage('E2E Tests') {
when {
expression { env.RUN_E2E == 'true' }
}
steps {
sh 'npm run test:e2e'
}
}
stage('SonarQube Analysis') {
when {
expression { env.SONAR_ENABLED == 'true' }
}
steps {
withSonarQubeEnv('sonar-server') {
sh 'npm run sonar'
}
timeout(time: 10, unit: 'MINUTES') {
waitForQualityGate abortPipeline: true
}
}
}
}
}
def shouldRunE2E() {
// Run E2E for main branches and PRs targeting them
if (env.BRANCH_NAME in ['main', 'develop']) return true
if (env.CHANGE_TARGET in ['main', 'develop']) return true
return false
}
def shouldDeploy() {
// Only deploy from specific branches
def deployBranches = ['main', 'develop', ~/release\/.*/]
return deployBranches.any { pattern ->
if (pattern instanceof java.util.regex.Pattern) {
return env.BRANCH_NAME ==~ pattern
}
return env.BRANCH_NAME == pattern
}
}
def shouldRunSonar() {
// Run SonarQube on main branches and weekly on features
if (env.BRANCH_NAME in ['main', 'develop']) return true
if (env.BRANCH_NAME?.startsWith('feature/')) {
// Only on scheduled builds, not every push
return currentBuild.getBuildCauses('hudson.triggers.TimerTrigger$TimerTriggerCause')
}
return false
} Multi-branch Pipeline Matrix
| Branch Pattern | Build | Test | Deploy | Approval | Cleanup |
|---|---|---|---|---|---|
| main | Full | All | Production | Required | Never |
| develop | Full | All | Staging | Auto | Never |
| release/* | Full | All | Staging | Required | After merge |
| feature/* | Quick | Unit | Dev | Auto | 7 days |
| hotfix/* | Full | All | Production | Required | After merge |
| PR to main | Full | All | Preview | N/A | After close |
Practice Question
In a Jenkins Multi-branch Pipeline, what happens when a branch is deleted from the repository?