<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 "❌ *파이프라인 실패 (원인 미상)*"
}
}