<aside> 📌

업데이트

pipeline {
    agent { label 'mac' }

    parameters {
        string(name: 'PRJ_URL', defaultValue: '<https://github.com/Geekble-Game/SerpentOfTheEnd-client.git>', description: 'GitHub repository URL')
        string(name: 'BRANCH', defaultValue: 'feat/playstore', description: 'Branch to build')
        string(name: 'UNITY_VER', defaultValue: '6000.0.26f1', description: 'Unity Editor version')
        string(name: 'BUILD_ROOT', defaultValue: '/Users/msg/Desktop/build', description: 'Build output root directory')
        string(name: 'BUILDFILE_PREFIX', defaultValue: 'SE', description: 'Build File Prefix')
        booleanParam(name: 'UNITY_BUILD_AAB', defaultValue: true, description: 'Build Android App Bundle (AAB)')

        string(name: 'PACKAGE_NAME', defaultValue: 'com.msg.serpent.dev', description: 'Play Store App Package Name')
        string(name: 'RELEASE_STATUS', defaultValue: 'draft', description: 'Play store Release status')
        choice(name: 'TRACK', choices: ['internal', 'beta', 'production'], description: 'Play Store Track')

        booleanParam(name: 'SKIP_BUILD', defaultValue: true, description: 'Skip Unity Build (Deploy Only)')
        string(name: 'TEST_AAB_PATH', defaultValue: '', description: 'Manual AAB path to deploy (auto-filled if empty)')
    }

    environment {
        UNITY_PATH = "/Applications/Unity/Hub/Editor/${params.UNITY_VER}/Unity.app/Contents/MacOS/Unity"
    }

    options {
        disableConcurrentBuilds()
    }

    stages {
        stage('Clean Workspace') {
            steps { cleanWs() }
        }

        stage('Checkout with Submodules') {
            steps {
                checkout([
                    $class: 'GitSCM',
                    branches: [[name: "*/${params.BRANCH}"]],
                    userRemoteConfigs: [[
                        url: "${params.PRJ_URL}",
                        credentialsId: 'SerpentOfTheEnd_Jenkins_Polling_TOKEN'
                    ]],
                    extensions: [
                        [$class: 'SubmoduleOption', recursiveSubmodules: true, trackingSubmodules: true],
                        [$class: 'RelativeTargetDirectory', relativeTargetDir: 'SerpentOfTheEnd-client']
                    ]
                ])
            }
        }

        stage('Init Project Info & Extract Version') {
            steps {
                script {
                    def (repoName, projectName) = parseProjectInfoFromRepoUrl(params.PRJ_URL)
                    env.BUILD_REPO_NAME = repoName
                    env.BUILD_PROJECT_NAME = projectName
                    env.BUILD_VERSION = getUnityVersionCode()
                    env.BUILD_NAME = getBuildName(env.BUILD_VERSION)
                    env.DEFAULT_BUILD_PATH = "${env.BUILD_PROJECT_NAME}/${params.BRANCH}/${env.BUILD_VERSION}"
                    env.LOG_DIR = "${params.BUILD_ROOT}/${env.DEFAULT_BUILD_PATH}/Logs"

                    if (params.SKIP_BUILD && !params.TEST_AAB_PATH) {
                        env.BUILD_OUTPUT_PATH = "${params.BUILD_ROOT}/${env.DEFAULT_BUILD_PATH}/Builds/android/${env.BUILD_NAME}.aab"
                    }

                    def actualPackageName = getUnityPackageName()
                    if (actualPackageName != params.PACKAGE_NAME) {
                        error "[ERROR] PACKAGE_NAME mismatch. Pipeline param: '${params.PACKAGE_NAME}', Project setting: '${actualPackageName}'"
                    }
                }
            }
        }

        stage('Send Build Start Alarm') {
            steps {
                script {
                    sendSlack("START", "#439FE0")
                }
            }
        }

        stage('Build Unity Android') {
            when { expression { return !params.SKIP_BUILD } }
            options { timeout(time: 1, unit: 'HOURS') }
            steps {
                withCredentials([
                    string(credentialsId: 'keystore_pass', variable: 'KEYSTORE_PASS'),
                    string(credentialsId: 'key_alias', variable: 'KEY_ALIAS'),
                    string(credentialsId: 'key_alias_pass', variable: 'KEY_ALIAS_PASS')
                ]) {
                    script {
                        def localBuildPath = "${params.BUILD_ROOT}/${env.DEFAULT_BUILD_PATH}"
                        def outputDir = "${localBuildPath}/Builds/android"
                        def logDir = "${localBuildPath}/Logs"
                        def ext = (params.UNITY_BUILD_AAB) ? 'aab' : 'apk'
                        def filePath = "${outputDir}/${env.BUILD_NAME}.${ext}"
                        def logPath = "${env.LOG_DIR}/${env.BUILD_NAME}.log"

                        def projectPath = "${env.WORKSPACE}/${env.BUILD_REPO_NAME}/${env.BUILD_PROJECT_NAME}"
                        def keystorePath = "${projectPath}.keystore"

                        env.BUILD_OUTPUT_PATH = filePath
                        env.BUILD_LOG_PATH = logPath
												
												try {
														withEnv([
		                            "UNITY_BUILD_PATH=${filePath}",
		                            "UNITY_BUILD_AAB=${params.UNITY_BUILD_AAB}",
		                            "KEYSTORE_PATH=${keystorePath}",
		                            "KEYSTORE_PASS=${env.KEYSTORE_PASS}",
		                            "KEY_ALIAS=${env.KEY_ALIAS}",
		                            "KEY_ALIAS_PASS=${env.KEY_ALIAS_PASS}"
		                        ]) {
		                            sh """
		                                mkdir -p "${logDir}"
		
		                                "${env.UNITY_PATH}" \
		                                  -batchmode \
		                                  -nographics \
		                                  -projectPath "${projectPath}" \
		                                  -executeMethod AndroidBuilder.BuildAndroid \
		                                  -quit \
		                                  -logFile "${logPath}" \
		                                  -buildTarget android
		                            """
		                        }
												} catch (err) {
														currentBuild.description = "BUILD_FAILED"
                            env.BUILD_FAIL_LOG_URL = uploadToS3(logPath, "${env.DEFAULT_BUILD_PATH}/logs")
                            error "[ERROR] Unity Build Output File not found: ${filePath}"
                            throw err
												} finally {
												}
                        

                        if (!fileExists(filePath)) {
                            currentBuild.description = "BUILD_FAILED"
                            env.BUILD_FAIL_LOG_URL = uploadToS3(logPath, "${env.DEFAULT_BUILD_PATH}/logs")
                            error "[ERROR] Unity Build Output File not found: ${filePath}"
                        }
                    }
                }
            }
        }

        stage('Deploy to Play Store') {
            when { expression { return currentBuild.result != 'FAILURE' } }
            steps {
                withCredentials([file(credentialsId: 'GOOGLE_PLAYSTORE_API_JSON', variable: 'GOOGLE_PLAY_JSON')]) {
                    script {
                        def fastlaneLogPath = "${env.LOG_DIR}/fastlane_deploy.log"
                        def deployAabPath = params.SKIP_BUILD ? params.TEST_AAB_PATH : env.BUILD_OUTPUT_PATH
	                      def defaultLang = "ko-KR"

                        sh """
												    mkdir -p "${env.WORKSPACE}/${env.BUILD_REPO_NAME}/metadata/android/${params.TRACK}/${defaultLang}/changelogs"
												"""

                        try {
                            withEnv([
														  "UNITY_BUILD_VERSION=${env.BUILD_VERSION}",
														  "AAB_PATH=${env.BUILD_OUTPUT_PATH}",
														  "GOOGLE_PLAY_JSON_KEY=${GOOGLE_PLAY_JSON}",
														  "PACKAGE_NAME=${params.PACKAGE_NAME}",
														  "TRACK=${params.TRACK}",
														  "RELEASE_STATUS=${params.RELEASE_STATUS}",
														  "DEFAULT_LANGUAGE=${defaultLang}",
														  "PATH=/opt/homebrew/bin:/usr/local/bin:$PATH"
														]) {
                                sh """
                                    set -o pipefail
                                    cd ${env.WORKSPACE}/${env.BUILD_REPO_NAME}
                                    fastlane android deploy 2>&1 | tee "${fastlaneLogPath}"
                                """
                            }
                        } catch (err) {
                            currentBuild.description = "DEPLOY_FAILED"
                            throw err
                        } finally {
                            if (fileExists(fastlaneLogPath)) {
                                env.BUILD_FASTLANE_LOG_URL = uploadToS3(fastlaneLogPath, "${env.DEFAULT_BUILD_PATH}/logs")
                            } else {
                                echo "[WARN] fastlane log not found: ${fastlaneLogPath}"
                            }
                        }
                    }
                }
            }
        }

        stage('Upload to S3') {
            when {
                expression {
                    return !["BUILD_FAILED", "DEPLOY_FAILED"].contains(currentBuild.description)
                }
            }
            steps {
                withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', credentialsId: 'AWS_CREDENTIALS']]) {
                    script {
                        env.BUILD_S3_URL = uploadToS3(env.BUILD_OUTPUT_PATH, env.DEFAULT_BUILD_PATH)
                    }
                }
            }
        }
    }

    post {
        always {
            script {
                def failReason = currentBuild.description?.trim()
                if (!failReason || failReason == '') {
                    echo "[IF] No failure reason set. Assuming SUCCESS."
                    sendSlack("SUCCESS", "#36a64f")
                } else {
                    switch (failReason) {
                        case "BUILD_FAILED":
                            sendSlack("BUILD_FAILED", "#FF0000")
                            break
                        case "DEPLOY_FAILED":
                            sendSlack("DEPLOY_FAILED", "#FF0000")
                            break
                        default:
                            sendSlack("UNKNOWN", "#808080")
                            break
                    }
                }
            }
        }
    }
}

// 유틸 함수
def parseProjectInfoFromRepoUrl(repoUrl) {
    def repoName = repoUrl.tokenize('/').last().replace('.git', '')
    def projectName = repoName.replaceFirst(/-client$/, '')
    return [repoName, projectName]
}

def getUnityVersionCode() {
    def versionFile = "${env.WORKSPACE}/${env.BUILD_REPO_NAME}/${env.BUILD_PROJECT_NAME}/ProjectSettings/ProjectSettings.asset"
    if (!fileExists(versionFile)) {
        error "[ERROR] ProjectSettings.asset not found: ${versionFile}"
    }
    def versionName = sh(
        script: """
            grep 'bundleVersion:' "${versionFile}" \
            | head -n 1 \
            | sed 's/.*:[ ]*//'
        """,
        returnStdout: true
    ).trim()
    if (!versionName) {
        error "[ERROR] bundleVersion not found in ${versionFile}"
    }
    echo "[INFO] Extracted VERSION_NAME: ${versionName}"
    return "${versionName}"
}

def getUnityPackageName() {
    def settingFile = "${env.WORKSPACE}/${env.BUILD_REPO_NAME}/${env.BUILD_PROJECT_NAME}/ProjectSettings/ProjectSettings.asset"
    if (!fileExists(settingFile)) {
        error "[ERROR] ProjectSettings.asset not found: ${settingFile}"
    }

    def identifier = sh(
    script: """
        awk '/^[[:space:]]*applicationIdentifier:/ { found=1 } found && /Android:/ { print \$2; exit }' "${settingFile}"
		    """,
		    returnStdout: true
		).trim()

    if (!identifier) {
        error "[ERROR] Android package name (applicationIdentifier) not found in ${settingFile}"
    }

    return identifier
}

def getBuildName(version) {
    return "${params.BUILDFILE_PREFIX}_${version}"
}

def uploadToS3(filePath, uploadPath, region = 'ap-northeast-2') {
    def bucketName = "msg-game"
    def s3Path = "s3://${bucketName}/${uploadPath}/android/"
    def fileName = filePath.tokenize('/')[-1]
    def s3Url = "<https://$>{bucketName}.s3.${region}.amazonaws.com/${uploadPath}/android/${fileName}"
    sh """
        export PATH=/usr/local/bin:/opt/homebrew/bin:\$PATH
        aws s3 cp "${filePath}" "${s3Path}" --region ${region}
    """
    echo "[INFO] Uploaded to S3: ${s3Url}"
    return s3Url
}

def sendSlack(String status, String color) {
    def slackTitle = getSlackTitle(status)

    def fields = [
        ["title": "Project", "value": "${env.BUILD_PROJECT_NAME}", "short": true],
        ["title": "Branch", "value": "${params.BRANCH}", "short": true],
        ["title": "Job", "value": "${env.JOB_NAME} #${env.BUILD_NUMBER}", "short": true]
    ]

    def logLinks = []
    if (status == "SUCCESS") {
        if (env.BUILD_S3_URL) {
            logLinks << "<${env.BUILD_S3_URL}|📦 ${env.BUILD_NAME}.aab>"
        }
        if (env.BUILD_FASTLANE_LOG_URL) {
            logLinks << "<${env.BUILD_FASTLANE_LOG_URL}|🛠️ ${env.BUILD_NAME}_fastlane_deploy.log>"
        }
    } else {
        if (env.BUILD_FAIL_LOG_URL) {
            logLinks << "<${env.BUILD_FAIL_LOG_URL}|🧾 ${env.BUILD_NAME}_build.log>"
        }
        if (env.BUILD_FASTLANE_LOG_URL) {
            logLinks << "<${env.BUILD_FASTLANE_LOG_URL}|🛠️ ${env.BUILD_NAME}_fastlane_deploy.log>"
        }
    }

    if (logLinks) {
        fields << ["title": (status == "SUCCESS") ? "Artifacts" : "Logs", "value": logLinks.join("\n"), "short": false]
    }

    def payload = [
        text: "${slackTitle}",
        attachments: [[color: "${color}", fields: fields]]
    ]

    def curlBody = groovy.json.JsonOutput.toJson(payload)

    withCredentials([string(credentialsId: 'SLACK_WEBHOOK_URL', variable: 'SLACK_URL')]) {
        sh """
						curl -X POST -H 'Content-type: application/json' --data '${curlBody}' "$SLACK_URL"
				"""
    }
}

def getSlackTitle(String status) {
    switch (status) {
        case "START": return "🚀 *Unity Android 빌드 시작*"
        case "SUCCESS": return "✅ *Unity Android 빌드 및 배포 성공*"
        case "BUILD_FAILED": return "❌ *Unity Android 빌드 실패*"
        case "DEPLOY_FAILED": return "❌ *Play Store 업로드 실패*"
        default: return "❌ *파이프라인 실패 (원인 미상)*"
    }
}