자동빌드 배포 오류 수정

This commit is contained in:
Macbook
2026-06-15 15:44:43 +09:00
parent 6720a5ed46
commit cab5365b19
14 changed files with 499 additions and 23 deletions
@@ -1,8 +1,10 @@
package com.goldenchart.controller;
import com.goldenchart.dto.DesktopAppBuildReadinessDto;
import com.goldenchart.dto.DesktopAppBuildStatusDto;
import com.goldenchart.dto.DesktopAppBuildTriggerDto;
import com.goldenchart.dto.DesktopAppReleaseInfoDto;
import com.goldenchart.service.DesktopAppBuildReadinessService;
import com.goldenchart.service.DesktopAppBuildService;
import com.goldenchart.service.DesktopAppReleaseService;
import jakarta.servlet.http.HttpServletRequest;
@@ -22,6 +24,7 @@ public class DesktopAppReleaseController {
private final DesktopAppReleaseService releaseService;
private final DesktopAppBuildService buildService;
private final DesktopAppBuildReadinessService readinessService;
@GetMapping("/info")
public DesktopAppReleaseInfoDto info(HttpServletRequest request) {
@@ -33,6 +36,11 @@ public class DesktopAppReleaseController {
return buildService.getStatus();
}
@GetMapping("/build/readiness")
public DesktopAppBuildReadinessDto buildReadiness() {
return readinessService.getReadiness();
}
@PostMapping("/build")
public DesktopAppBuildTriggerDto triggerBuild(
@RequestHeader(value = "X-User-Id", required = false) Long userId) {
@@ -0,0 +1,22 @@
package com.goldenchart.dto;
import lombok.Builder;
import lombok.Value;
@Value
@Builder
public class DesktopAppBuildReadinessDto {
/** 현재 배포·다운로드 가능 버전 */
String publishedVersion;
/** 다음 빌드 성공 시 예상 버전 (published patch +1) */
String nextBuildVersion;
boolean buildRequired;
/** up_to_date | build_required | web_only | building | unknown */
String status;
String message;
String lastBuiltVersion;
String lastBuiltGitShort;
String sourceHeadGitShort;
int pendingCommitCount;
boolean desktopChangesPending;
}
@@ -0,0 +1,150 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.dto.DesktopAppBuildReadinessDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@Service
@Slf4j
@RequiredArgsConstructor
public class DesktopAppBuildReadinessService {
private final ObjectMapper objectMapper;
private final DesktopAppBuildService buildService;
@Value("${goldenchart.desktop-app.release-dir:data/desktop-releases}")
private String releaseDir;
public DesktopAppBuildReadinessDto getReadiness() {
Path release = releasePath();
JsonNode buildInfo = readJson(release.resolve("build-info.json"));
JsonNode sourceState = readJson(release.resolve("source-state.json"));
String published = text(buildInfo, "version");
String lastSha = text(buildInfo, "gitSha");
String lastShort = text(buildInfo, "gitShort");
String headSha = text(sourceState, "headSha");
String headShort = text(sourceState, "headShort");
boolean affectsDesktop = sourceState.path("affectsDesktop").asBoolean(true);
int pendingCommits = sourceState.path("pendingCommitCount").asInt(0);
String nextVersion = bumpPatch(published != null ? published : "0.1.0");
var jenkins = buildService.getStatus();
if (jenkins.isBuilding() || "queued".equals(jenkins.getStatus())) {
return DesktopAppBuildReadinessDto.builder()
.publishedVersion(published)
.nextBuildVersion(nextVersion)
.buildRequired(true)
.status("building")
.message("Jenkins 빌드 진행 중 (#" + jenkins.getBuildNumber() + "). 완료 후 v"
+ nextVersion + " 설치 파일이 반영됩니다.")
.lastBuiltVersion(published)
.lastBuiltGitShort(lastShort)
.sourceHeadGitShort(headShort)
.pendingCommitCount(pendingCommits)
.desktopChangesPending(affectsDesktop)
.build();
}
boolean shaKnown = headSha != null && !headSha.isBlank() && lastSha != null && !lastSha.isBlank();
boolean inSync = shaKnown && headSha.equalsIgnoreCase(lastSha);
if (inSync || (shaKnown && !affectsDesktop && pendingCommits == 0)) {
return DesktopAppBuildReadinessDto.builder()
.publishedVersion(published)
.nextBuildVersion(nextVersion)
.buildRequired(false)
.status("up_to_date")
.message(published != null
? "최신 소스가 반영되어 있습니다 (v" + published + "). 데스크톱 앱 빌드가 필요하지 않습니다."
: "설치 파일이 없습니다. 「앱 빌드」로 첫 빌드를 실행하세요.")
.lastBuiltVersion(published)
.lastBuiltGitShort(lastShort)
.sourceHeadGitShort(headShort)
.pendingCommitCount(0)
.desktopChangesPending(false)
.build();
}
if (shaKnown && !affectsDesktop) {
return DesktopAppBuildReadinessDto.builder()
.publishedVersion(published)
.nextBuildVersion(nextVersion)
.buildRequired(false)
.status("web_only")
.message("main에 미반영 커밋이 있으나 데스크톱 앱에 영향 없는 변경입니다. PC 빌드는 불필요합니다.")
.lastBuiltVersion(published)
.lastBuiltGitShort(lastShort)
.sourceHeadGitShort(headShort)
.pendingCommitCount(pendingCommits)
.desktopChangesPending(false)
.build();
}
String pendingMsg = pendingCommits > 0
? "데스크톱 영향 변경 " + pendingCommits + "커밋 미반영"
: "최신 소스가 아직 PC 빌드에 반영되지 않았습니다";
return DesktopAppBuildReadinessDto.builder()
.publishedVersion(published)
.nextBuildVersion(nextVersion)
.buildRequired(true)
.status("build_required")
.message(pendingMsg + ". 「앱 빌드」 시 v" + nextVersion + " (macOS·Windows)로 빌드됩니다.")
.lastBuiltVersion(published)
.lastBuiltGitShort(lastShort)
.sourceHeadGitShort(headShort)
.pendingCommitCount(pendingCommits)
.desktopChangesPending(true)
.build();
}
private Path releasePath() {
return Paths.get(releaseDir).toAbsolutePath().normalize();
}
private JsonNode readJson(Path path) {
if (!Files.isRegularFile(path)) {
return objectMapper.createObjectNode();
}
try {
return objectMapper.readTree(Files.readString(path));
} catch (IOException e) {
log.debug("[desktop-readiness] read {} failed: {}", path, e.getMessage());
return objectMapper.createObjectNode();
}
}
private static String text(JsonNode node, String field) {
if (node == null || node.isMissingNode()) return null;
JsonNode v = node.path(field);
if (!v.isTextual()) return null;
String t = v.asText().trim();
return t.isEmpty() ? null : t;
}
static String bumpPatch(String version) {
String[] parts = version.replaceAll("^[vV]", "").split("\\.");
int major = parts.length > 0 ? parseInt(parts[0]) : 0;
int minor = parts.length > 1 ? parseInt(parts[1]) : 0;
int patch = parts.length > 2 ? parseInt(parts[2]) : 0;
return major + "." + minor + "." + (patch + 1);
}
private static int parseInt(String s) {
try {
return Integer.parseInt(s.replaceAll("[^0-9].*", ""));
} catch (NumberFormatException e) {
return 0;
}
}
}
@@ -84,7 +84,19 @@ public class DesktopAppBuildService {
.build();
}
try {
return fetchLastBuildStatus();
DesktopAppBuildStatusDto last = fetchLastBuildStatus();
if (isJenkinsQueued() && !last.isBuilding()) {
return DesktopAppBuildStatusDto.builder()
.jenkinsEnabled(true)
.building(true)
.status("queued")
.buildNumber(last.getBuildNumber())
.message("Jenkins 큐 대기 중…")
.buildUrl(last.getBuildUrl())
.startedAtMs(last.getStartedAtMs())
.build();
}
return last;
} catch (Exception e) {
log.warn("[desktop-build] status failed: {}", e.getMessage());
return DesktopAppBuildStatusDto.builder()
@@ -110,12 +122,13 @@ public class DesktopAppBuildService {
if (hasBasicAuth()) {
try {
DesktopAppBuildStatusDto current = fetchLastBuildStatus();
if (current.isBuilding()) {
if (isJenkinsBusy()) {
DesktopAppBuildStatusDto current = fetchLastBuildStatus();
return DesktopAppBuildTriggerDto.builder()
.accepted(false)
.buildNumber(current.getBuildNumber())
.message("이미 빌드가 진행 중입니다 (#" + current.getBuildNumber() + ").")
.message("이미 빌드가 진행 중이거나 Jenkins 큐에 대기 중입니다 (#"
+ current.getBuildNumber() + "). 완료 후 다시 시도하세요.")
.build();
}
} catch (WebClientResponseException e) {
@@ -149,6 +162,49 @@ public class DesktopAppBuildService {
}
}
private boolean isJenkinsBusy() {
try {
DesktopAppBuildStatusDto last = fetchLastBuildStatus();
if (last.isBuilding()) {
return true;
}
if (last.getStatus() != null && "queued".equalsIgnoreCase(last.getStatus())) {
return true;
}
return isJenkinsQueued();
} catch (Exception e) {
log.debug("[desktop-build] busy check failed: {}", e.getMessage());
return false;
}
}
private boolean isJenkinsQueued() {
if (!hasBasicAuth()) {
return false;
}
try {
WebClient client = jenkinsClient();
JsonNode queue = client.get()
.uri("/queue/api/json?tree=items[task[name]]")
.retrieve()
.bodyToMono(JsonNode.class)
.block(Duration.ofSeconds(10));
if (queue == null || !queue.path("items").isArray()) {
return false;
}
String jobName = jenkinsJob.trim();
for (JsonNode item : queue.path("items")) {
String name = textOrNull(item.path("task").path("name"));
if (jobName.equals(name)) {
return true;
}
}
} catch (Exception e) {
log.debug("[desktop-build] queue check failed: {}", e.getMessage());
}
return false;
}
private DesktopAppBuildStatusDto fetchLastBuildStatus() {
WebClient client = jenkinsClient();
String jobPath = jobApiPath();