자동빌드 배포 오류 수정

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; package com.goldenchart.controller;
import com.goldenchart.dto.DesktopAppBuildReadinessDto;
import com.goldenchart.dto.DesktopAppBuildStatusDto; import com.goldenchart.dto.DesktopAppBuildStatusDto;
import com.goldenchart.dto.DesktopAppBuildTriggerDto; import com.goldenchart.dto.DesktopAppBuildTriggerDto;
import com.goldenchart.dto.DesktopAppReleaseInfoDto; import com.goldenchart.dto.DesktopAppReleaseInfoDto;
import com.goldenchart.service.DesktopAppBuildReadinessService;
import com.goldenchart.service.DesktopAppBuildService; import com.goldenchart.service.DesktopAppBuildService;
import com.goldenchart.service.DesktopAppReleaseService; import com.goldenchart.service.DesktopAppReleaseService;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -22,6 +24,7 @@ public class DesktopAppReleaseController {
private final DesktopAppReleaseService releaseService; private final DesktopAppReleaseService releaseService;
private final DesktopAppBuildService buildService; private final DesktopAppBuildService buildService;
private final DesktopAppBuildReadinessService readinessService;
@GetMapping("/info") @GetMapping("/info")
public DesktopAppReleaseInfoDto info(HttpServletRequest request) { public DesktopAppReleaseInfoDto info(HttpServletRequest request) {
@@ -33,6 +36,11 @@ public class DesktopAppReleaseController {
return buildService.getStatus(); return buildService.getStatus();
} }
@GetMapping("/build/readiness")
public DesktopAppBuildReadinessDto buildReadiness() {
return readinessService.getReadiness();
}
@PostMapping("/build") @PostMapping("/build")
public DesktopAppBuildTriggerDto triggerBuild( public DesktopAppBuildTriggerDto triggerBuild(
@RequestHeader(value = "X-User-Id", required = false) Long userId) { @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(); .build();
} }
try { 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) { } catch (Exception e) {
log.warn("[desktop-build] status failed: {}", e.getMessage()); log.warn("[desktop-build] status failed: {}", e.getMessage());
return DesktopAppBuildStatusDto.builder() return DesktopAppBuildStatusDto.builder()
@@ -110,12 +122,13 @@ public class DesktopAppBuildService {
if (hasBasicAuth()) { if (hasBasicAuth()) {
try { try {
if (isJenkinsBusy()) {
DesktopAppBuildStatusDto current = fetchLastBuildStatus(); DesktopAppBuildStatusDto current = fetchLastBuildStatus();
if (current.isBuilding()) {
return DesktopAppBuildTriggerDto.builder() return DesktopAppBuildTriggerDto.builder()
.accepted(false) .accepted(false)
.buildNumber(current.getBuildNumber()) .buildNumber(current.getBuildNumber())
.message("이미 빌드가 진행 중입니다 (#" + current.getBuildNumber() + ").") .message("이미 빌드가 진행 중이거나 Jenkins 큐에 대기 중입니다 (#"
+ current.getBuildNumber() + "). 완료 후 다시 시도하세요.")
.build(); .build();
} }
} catch (WebClientResponseException e) { } 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() { private DesktopAppBuildStatusDto fetchLastBuildStatus() {
WebClient client = jenkinsClient(); WebClient client = jenkinsClient();
String jobPath = jobApiPath(); String jobPath = jobApiPath();
+38
View File
@@ -13309,6 +13309,44 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
.app-download-build-btn:hover:not(:disabled) { filter: brightness(1.08); } .app-download-build-btn:hover:not(:disabled) { filter: brightness(1.08); }
.app-download-build-btn:disabled, .app-download-build-btn.busy { opacity: 0.65; cursor: wait; } .app-download-build-btn:disabled, .app-download-build-btn.busy { opacity: 0.65; cursor: wait; }
.app-download-build-btn.busy svg { animation: tmb-update-spin 0.9s linear infinite; } .app-download-build-btn.busy svg { animation: tmb-update-spin 0.9s linear infinite; }
.app-download-readiness {
margin: 0 0 12px; padding: 10px 12px; border-radius: 10px;
border: 1px solid var(--border); background: var(--bg2);
font-size: 12px; line-height: 1.45;
}
.app-download-readiness-badge {
display: inline-block; font-size: 11px; font-weight: 700;
padding: 2px 8px; border-radius: 999px; margin-bottom: 6px;
}
.app-download-readiness-msg { margin: 0; color: var(--text2); }
.app-download-readiness-ver { margin: 6px 0 0; font-size: 11px; color: var(--text3); }
.app-download-readiness--up_to_date {
border-color: color-mix(in srgb, #22c55e 40%, var(--border));
background: color-mix(in srgb, #22c55e 8%, var(--bg2));
}
.app-download-readiness--up_to_date .app-download-readiness-badge {
background: color-mix(in srgb, #22c55e 20%, var(--bg)); color: #4ade80;
}
.app-download-readiness--web_only {
border-color: color-mix(in srgb, #94a3b8 40%, var(--border));
}
.app-download-readiness--web_only .app-download-readiness-badge {
background: color-mix(in srgb, #94a3b8 20%, var(--bg)); color: var(--text2);
}
.app-download-readiness--build_required {
border-color: color-mix(in srgb, #f59e0b 45%, var(--border));
background: color-mix(in srgb, #f59e0b 8%, var(--bg2));
}
.app-download-readiness--build_required .app-download-readiness-badge {
background: color-mix(in srgb, #f59e0b 22%, var(--bg)); color: #fbbf24;
}
.app-download-readiness--building {
border-color: color-mix(in srgb, #2196f3 45%, var(--border));
background: color-mix(in srgb, #2196f3 8%, var(--bg2));
}
.app-download-readiness--building .app-download-readiness-badge {
background: color-mix(in srgb, #2196f3 20%, var(--bg)); color: #5eb5ff;
}
.app-download-build-status { .app-download-build-status {
margin: -8px 0 12px; font-size: 12px; color: var(--text2); line-height: 1.45; margin: -8px 0 12px; font-size: 12px; color: var(--text2); line-height: 1.45;
} }
+65 -16
View File
@@ -1,14 +1,16 @@
/** /**
* 모바일·PC 프로그램 설치 — 탭 UI (모바일 QR / PC dmg·exe + Jenkins 빌드) * 모바일·PC 프로그램 설치 — 탭 UI (모바일 QR / PC dmg·exe + Jenkins 빌드)
*/ */
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import DraggableModalFrame from './DraggableModalFrame'; import DraggableModalFrame from './DraggableModalFrame';
import { import {
loadDesktopAppBuildReadiness,
loadDesktopAppBuildStatus, loadDesktopAppBuildStatus,
loadDesktopAppReleaseInfo, loadDesktopAppReleaseInfo,
loadMobileAppReleaseInfo, loadMobileAppReleaseInfo,
triggerDesktopAppBuild, triggerDesktopAppBuild,
type DesktopAppBuildReadinessDto,
type DesktopAppBuildStatusDto, type DesktopAppBuildStatusDto,
type DesktopAppPlatformReleaseDto, type DesktopAppPlatformReleaseDto,
type DesktopAppReleaseInfoDto, type DesktopAppReleaseInfoDto,
@@ -351,12 +353,14 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
const [info, setInfo] = useState<DesktopAppReleaseInfoDto | null>(null); const [info, setInfo] = useState<DesktopAppReleaseInfoDto | null>(null);
const [mobileInfo, setMobileInfo] = useState<MobileAppReleaseInfoDto | null>(null); const [mobileInfo, setMobileInfo] = useState<MobileAppReleaseInfoDto | null>(null);
const [buildStatus, setBuildStatus] = useState<DesktopAppBuildStatusDto | null>(null); const [buildStatus, setBuildStatus] = useState<DesktopAppBuildStatusDto | null>(null);
const [readiness, setReadiness] = useState<DesktopAppBuildReadinessDto | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [buildBusy, setBuildBusy] = useState(false); const [buildBusy, setBuildBusy] = useState(false);
const [buildHint, setBuildHint] = useState<string | null>(null); const [buildHint, setBuildHint] = useState<string | null>(null);
const [buildTracking, setBuildTracking] = useState(false); const [buildTracking, setBuildTracking] = useState(false);
const [elapsedTick, setElapsedTick] = useState(0); const [elapsedTick, setElapsedTick] = useState(0);
const autoTrackStarted = useRef(false);
const reloadRelease = useCallback(async () => { const reloadRelease = useCallback(async () => {
const [desktop, mobile] = await Promise.all([ const [desktop, mobile] = await Promise.all([
@@ -374,19 +378,28 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
return st; return st;
}, []); }, []);
const reloadReadiness = useCallback(async () => {
const rd = await loadDesktopAppBuildReadiness();
setReadiness(rd);
return rd;
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void Promise.all([reloadRelease(), reloadBuildStatus()]) void Promise.all([reloadRelease(), reloadBuildStatus(), reloadReadiness()])
.catch(() => { if (!cancelled) setError('PC 프로그램 정보를 불러오지 못했습니다.'); }) .catch(() => { if (!cancelled) setError('PC 프로그램 정보를 불러오지 못했습니다.'); })
.finally(() => { if (!cancelled) setLoading(false); }); .finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [reloadRelease, reloadBuildStatus]); }, [reloadRelease, reloadBuildStatus, reloadReadiness]);
const isBuilding = Boolean(buildStatus?.building || buildStatus?.status === 'queued'); const isBuilding = Boolean(buildStatus?.building || buildStatus?.status === 'queued');
const showBuildProgress = Boolean( const showBuildProgress = Boolean(buildBusy || buildTracking || isBuilding);
buildBusy || buildTracking || isBuilding
|| (buildStatus?.buildNumber && buildStatus.status !== 'idle' && buildStatus.status !== 'ready'), useEffect(() => {
); if (loading || autoTrackStarted.current || !isBuilding) return;
autoTrackStarted.current = true;
setBuildTracking(true);
}, [loading, isBuilding]);
useEffect(() => { useEffect(() => {
if (!showBuildProgress || buildStatus?.startedAtMs == null) return; if (!showBuildProgress || buildStatus?.startedAtMs == null) return;
@@ -401,27 +414,38 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
if (!st) return; if (!st) return;
if (isTerminalBuildStatus(st.status, st.building)) { if (isTerminalBuildStatus(st.status, st.building)) {
setBuildTracking(false); setBuildTracking(false);
void reloadRelease(); void Promise.all([reloadRelease(), reloadReadiness()]).then(([desktop]) => {
} else if (!st.building && st.status !== 'queued') { if (st.status === 'success' && readiness?.nextBuildVersion && desktop?.version) {
void reloadRelease(); if (desktop.version === readiness.nextBuildVersion) {
setBuildHint(`빌드 완료 — v${desktop.version} 설치 파일이 반영되었습니다.`);
} else {
setBuildHint(`빌드 완료 — v${desktop.version} (예상 v${readiness.nextBuildVersion})`);
}
} else if (st.status === 'failure') {
setBuildHint(st.message ?? '빌드 실패 — Jenkins 로그를 확인하세요.');
}
});
} }
}); });
}; };
poll(); poll();
const timer = window.setInterval(poll, BUILD_POLL_MS); const timer = window.setInterval(poll, BUILD_POLL_MS);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [buildTracking, isBuilding, buildBusy, reloadBuildStatus, reloadRelease]); }, [buildTracking, isBuilding, buildBusy, reloadBuildStatus, reloadRelease, reloadReadiness, readiness?.nextBuildVersion]);
const handleBuild = useCallback(async () => { const handleBuild = useCallback(async () => {
if (!canBuildDesktop || buildBusy || isBuilding) return; if (!canBuildDesktop || buildBusy || isBuilding) return;
setBuildBusy(true); setBuildBusy(true);
setBuildTracking(true); setBuildTracking(true);
setBuildHint('Jenkins 빌드 요청 중…'); const nextVer = readiness?.nextBuildVersion;
setBuildHint(nextVer
? `Jenkins 빌드 요청 중… (예상 v${nextVer})`
: 'Jenkins 빌드 요청 중…');
try { try {
const res = await triggerDesktopAppBuild(); const res = await triggerDesktopAppBuild();
setBuildHint(res.message ?? (res.accepted ? '빌드 시작됨' : '빌드 요청 거부')); setBuildHint(res.message ?? (res.accepted ? '빌드 시작됨' : '빌드 요청 거부'));
if (!res.accepted) setBuildTracking(false); if (!res.accepted) setBuildTracking(false);
await reloadBuildStatus(); await Promise.all([reloadBuildStatus(), reloadReadiness()]);
if (res.accepted) { if (res.accepted) {
window.setTimeout(() => { void reloadBuildStatus(); }, 2000); window.setTimeout(() => { void reloadBuildStatus(); }, 2000);
} }
@@ -431,7 +455,7 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
} finally { } finally {
setBuildBusy(false); setBuildBusy(false);
} }
}, [canBuildDesktop, buildBusy, isBuilding, reloadBuildStatus]); }, [canBuildDesktop, buildBusy, isBuilding, reloadBuildStatus, reloadReadiness, readiness?.nextBuildVersion]);
const hasAny = info?.mac?.available || info?.windows?.available || mobileInfo?.available; const hasAny = info?.mac?.available || info?.windows?.available || mobileInfo?.available;
@@ -443,10 +467,21 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
), ),
); );
const readinessLabel = useMemo(() => {
if (!readiness) return null;
switch (readiness.status) {
case 'up_to_date': return '최신 버전';
case 'web_only': return 'PC 빌드 불필요';
case 'build_required': return '빌드 필요';
case 'building': return '빌드 진행 중';
default: return readiness.buildRequired ? '빌드 필요' : '상태 확인';
}
}, [readiness]);
if (loading) return <p className="app-download-muted">PC </p>; if (loading) return <p className="app-download-muted">PC </p>;
if (error) return <p className="app-download-error">{error}</p>; if (error) return <p className="app-download-error">{error}</p>;
const statusMessage = !showBuildProgress ? (buildHint ?? buildStatus?.message) : null; const statusMessage = !showBuildProgress ? (buildHint ?? (readiness?.status === 'up_to_date' ? readiness.message : buildStatus?.message)) : null;
return ( return (
<> <>
@@ -467,7 +502,9 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
className={`app-download-build-btn${buildBusy || isBuilding ? ' busy' : ''}`} className={`app-download-build-btn${buildBusy || isBuilding ? ' busy' : ''}`}
onClick={() => { void handleBuild(); }} onClick={() => { void handleBuild(); }}
disabled={buildBusy || isBuilding} disabled={buildBusy || isBuilding}
title="Git main 최신 소스로 macOS·Windows 데스크톱 앱 Jenkins 빌드" title={readiness?.buildRequired
? `다음 빌드 예상 버전 v${readiness.nextBuildVersion ?? '?'}`
: '현재 최신 소스가 PC 빌드에 반영되어 있습니다'}
aria-label="데스크톱 앱 빌드" aria-label="데스크톱 앱 빌드"
> >
<IcDesktopBuild /> <IcDesktopBuild />
@@ -476,6 +513,18 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
)} )}
</div> </div>
{readiness && readinessLabel && (
<div className={`app-download-readiness app-download-readiness--${readiness.status}`}>
<span className="app-download-readiness-badge">{readinessLabel}</span>
<p className="app-download-readiness-msg">{readiness.message}</p>
{readiness.publishedVersion && readiness.nextBuildVersion && readiness.buildRequired && (
<p className="app-download-readiness-ver">
v{readiness.publishedVersion} v{readiness.nextBuildVersion}
</p>
)}
</div>
)}
{desktopVersionMismatch && ( {desktopVersionMismatch && (
<p className="app-download-error" style={{ marginBottom: 8 }}> <p className="app-download-error" style={{ marginBottom: 8 }}>
macOS·Windows (v{info?.version}) . . macOS·Windows (v{info?.version}) . .
+17
View File
@@ -719,6 +719,23 @@ export async function loadDesktopAppBuildStatus(): Promise<DesktopAppBuildStatus
return request<DesktopAppBuildStatusDto>('/desktop-app/build/status'); return request<DesktopAppBuildStatusDto>('/desktop-app/build/status');
} }
export interface DesktopAppBuildReadinessDto {
publishedVersion?: string;
nextBuildVersion?: string;
buildRequired: boolean;
status: string;
message?: string;
lastBuiltVersion?: string;
lastBuiltGitShort?: string;
sourceHeadGitShort?: string;
pendingCommitCount?: number;
desktopChangesPending?: boolean;
}
export async function loadDesktopAppBuildReadiness(): Promise<DesktopAppBuildReadinessDto | null> {
return request<DesktopAppBuildReadinessDto>('/desktop-app/build/readiness');
}
export async function triggerDesktopAppBuild(): Promise<DesktopAppBuildTriggerDto> { export async function triggerDesktopAppBuild(): Promise<DesktopAppBuildTriggerDto> {
return requestOrThrow<DesktopAppBuildTriggerDto>('/desktop-app/build', { method: 'POST' }); return requestOrThrow<DesktopAppBuildTriggerDto>('/desktop-app/build', { method: 'POST' });
} }
@@ -74,6 +74,13 @@ if (releaseDir && fs.existsSync(releaseDir)) {
const m = name.match(/GoldenChart[_-](\\d+\\.\\d+\\.\\d+)/i); const m = name.match(/GoldenChart[_-](\\d+\\.\\d+\\.\\d+)/i);
if (m) base = maxVer(base, m[1]); if (m) base = maxVer(base, m[1]);
} }
const releaseBuildInfo = path.join(releaseDir, 'build-info.json');
if (fs.existsSync(releaseBuildInfo)) {
try {
const bi = JSON.parse(fs.readFileSync(releaseBuildInfo, 'utf8'));
if (bi.version) base = maxVer(base, bi.version);
} catch { /* ignore */ }
}
} }
const parts = parse(base); const parts = parse(base);
+3
View File
@@ -26,6 +26,9 @@ if [[ -d "$GIT_DIR" ]]; then
git --git-dir="$GIT_DIR" --work-tree="$WORK_TREE" checkout -f main 2>/dev/null || true git --git-dir="$GIT_DIR" --work-tree="$WORK_TREE" checkout -f main 2>/dev/null || true
fi fi
chmod +x "$WORK_TREE/scripts/"*.sh 2>/dev/null || true
"$WORK_TREE/scripts/prepare-desktop-jenkins-build.sh"
# Jenkins SCM workspace 가 있으면 scripts 를 WORK_TREE 로 동기 (구 스크립트 방지) # Jenkins SCM workspace 가 있으면 scripts 를 WORK_TREE 로 동기 (구 스크립트 방지)
if [[ -n "${WORKSPACE:-}" && -d "${WORKSPACE}/scripts" && "${WORKSPACE}" != "$WORK_TREE" ]]; then if [[ -n "${WORKSPACE:-}" && -d "${WORKSPACE}/scripts" && "${WORKSPACE}" != "$WORK_TREE" ]]; then
log "Sync scripts: ${WORKSPACE}/scripts → ${WORK_TREE}/scripts" log "Sync scripts: ${WORKSPACE}/scripts → ${WORK_TREE}/scripts"
+17 -3
View File
@@ -49,17 +49,31 @@ while read -r OLDREV NEWREV REFNAME; do
WORK_TREE="$WORK_TREE" "$RELOAD" >> "$DEPLOY_LOG" 2>&1 || true WORK_TREE="$WORK_TREE" "$RELOAD" >> "$DEPLOY_LOG" 2>&1 || true
fi fi
# Desktop(macOS·Windows) Jenkins 빌드 — git push 성공 후 자동 (웹 배포와 독립) chmod +x "$WORK_TREE/scripts/update-desktop-source-state.sh" 2>/dev/null || true
if [[ -x "$WORK_TREE/scripts/update-desktop-source-state.sh" ]]; then
WORK_TREE="$WORK_TREE" "$WORK_TREE/scripts/update-desktop-source-state.sh" || true
fi
# Desktop(macOS·Windows) Jenkins 빌드 — 데스크톱 영향 변경 시에만 자동 트리거
WORK_TREE="${WORK_TREE:-/Users/aidev/apps/goldenChart}" WORK_TREE="${WORK_TREE:-/Users/aidev/apps/goldenChart}"
TRIGGER="${WORK_TREE}/scripts/trigger-desktop-jenkins-build.sh" TRIGGER="${WORK_TREE}/scripts/trigger-desktop-jenkins-build.sh"
if [ -x "$TRIGGER" ]; then DESKTOP_BUILD_NEEDED=1
if [[ -x "$WORK_TREE/scripts/desktop-version-scope.sh" ]]; then
# shellcheck disable=SC1091
source "$WORK_TREE/scripts/desktop-version-scope.sh"
if ! desktop_version_diff_affects_app "$OLDREV..$NEWREV"; then
DESKTOP_BUILD_NEEDED=0
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG Desktop Jenkins 스kip — 데스크톱 영향 없는 변경"
fi
fi
if [ "$DESKTOP_BUILD_NEEDED" = "1" ] && [ -x "$TRIGGER" ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG Desktop Jenkins 빌드 트리거" echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG Desktop Jenkins 빌드 트리거"
if "$TRIGGER" >> "$DEPLOY_LOG" 2>&1; then if "$TRIGGER" >> "$DEPLOY_LOG" 2>&1; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG Desktop 빌드 큐 등록 완료" echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG Desktop 빌드 큐 등록 완료"
else else
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG [WARN] Desktop Jenkins 트리거 실패 (웹 배포는 성공)" echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG [WARN] Desktop Jenkins 트리거 실패 (웹 배포는 성공)"
fi fi
else elif [ "$DESKTOP_BUILD_NEEDED" = "1" ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG [WARN] trigger-desktop-jenkins-build.sh 없음: $TRIGGER" echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG [WARN] trigger-desktop-jenkins-build.sh 없음: $TRIGGER"
fi fi
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Jenkins Desktop 빌드 시작 전 — git 동기화·deps·source-state (1회 성공률 향상)
set -euo pipefail
WORK_TREE="${WORK_TREE:-/Users/aidev/apps/goldenChart}"
ROOT="$WORK_TREE"
export WORK_TREE
export PATH="/opt/homebrew/opt/llvm/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:${PATH:-/usr/bin:/bin}"
for prefix in "$HOME/homebrew" /opt/homebrew; do
[[ -x "$prefix/bin/brew" ]] && eval "$("$prefix/bin/brew" shellenv)"
done
# shellcheck disable=SC1091
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
log() { echo "[prepare-desktop $(date '+%H:%M:%S')] $*"; }
GIT_DIR="${GIT_DIR:-/Volumes/ADATA/git/goldenChart.git}"
if [[ -d "$GIT_DIR" ]]; then
log "git fetch + checkout main"
if git --git-dir="$GIT_DIR" remote get-url origin >/dev/null 2>&1; then
git --git-dir="$GIT_DIR" fetch origin main 2>/dev/null || true
fi
git --git-dir="$GIT_DIR" --work-tree="$WORK_TREE" checkout -f main 2>/dev/null || true
git --git-dir="$GIT_DIR" --work-tree="$WORK_TREE" reset --hard main 2>/dev/null || true
fi
chmod +x "$WORK_TREE/scripts/"*.sh 2>/dev/null || true
"$WORK_TREE/scripts/update-desktop-source-state.sh"
if ! command -v cargo-xwin >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; then
log "desktop build deps 설치"
"$WORK_TREE/scripts/install-desktop-build-deps.sh"
fi
if [[ -f "$WORK_TREE/desktop/updater.pub" ]]; then
"$WORK_TREE/scripts/patch-tauri-updater-pubkey.sh"
fi
log "HEAD $(git -C "$WORK_TREE" rev-parse --short HEAD 2>/dev/null || echo n/a)"
log "prepare OK"
+5
View File
@@ -180,4 +180,9 @@ log "Published v${VERSION}:"
ls -la "$PUBLISH_UPDATES" ls -la "$PUBLISH_UPDATES"
log "nginx: /desktop/updates/ → frontend/public/desktop/updates (Docker rebuild 시 반영)" log "nginx: /desktop/updates/ → frontend/public/desktop/updates (Docker rebuild 시 반영)"
if [[ -x "$WORK_TREE/scripts/update-desktop-source-state.sh" ]]; then
WORK_TREE="$WORK_TREE" "$WORK_TREE/scripts/update-desktop-source-state.sh" || true
fi
log "Done." log "Done."
+10
View File
@@ -56,6 +56,16 @@ if curl_get "${JOB_API}/lastBuild/api/json?tree=building" 2>/dev/null | grep -q
exit 0 exit 0
fi fi
if curl_get "${JENKINS_URL}/queue/api/json?tree=items[task[name]]" 2>/dev/null | grep -q "\"name\":\"${JENKINS_JOB}\""; then
log "[SKIP] Jenkins 큐에 동일 Job 대기 중"
exit 0
fi
WORK_TREE="${WORK_TREE:-/Users/aidev/apps/goldenChart}"
if [[ -x "$WORK_TREE/scripts/update-desktop-source-state.sh" ]]; then
WORK_TREE="$WORK_TREE" "$WORK_TREE/scripts/update-desktop-source-state.sh" || true
fi
parse_crumb_field() { parse_crumb_field() {
python3 -c "import sys,json; print(json.load(sys.stdin).get('crumbRequestField',''))" 2>/dev/null || true python3 -c "import sys,json; print(json.load(sys.stdin).get('crumbRequestField',''))" 2>/dev/null || true
} }
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# main HEAD vs 마지막 성공 PC 빌드(build-info) 비교 → source-state.json (API·UI용)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK_TREE="${WORK_TREE:-$ROOT}"
RELEASE_DIR="${GC_DESKTOP_APP_RELEASE_DIR:-$WORK_TREE/data/desktop-releases}"
BUILD_INFO="$RELEASE_DIR/build-info.json"
OUT="$RELEASE_DIR/source-state.json"
mkdir -p "$RELEASE_DIR"
HEAD_SHA="$(git -C "$WORK_TREE" rev-parse HEAD 2>/dev/null || echo '')"
HEAD_SHORT="$(git -C "$WORK_TREE" rev-parse --short HEAD 2>/dev/null || echo '')"
LAST_SHA=""
PUBLISHED_VER=""
if [[ -f "$BUILD_INFO" ]]; then
read -r LAST_SHA PUBLISHED_VER < <(node -e "
const fs=require('fs');
try {
const j=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));
console.log((j.gitSha||'')+' '+(j.version||''));
} catch { console.log(' '); }
" "$BUILD_INFO")
fi
PENDING=0
AFFECTS=0
if [[ -n "$HEAD_SHA" && -n "$LAST_SHA" && "$HEAD_SHA" != "$LAST_SHA" ]]; then
PENDING="$(git -C "$WORK_TREE" rev-list --count "${LAST_SHA}..${HEAD_SHA}" 2>/dev/null || echo 0)"
# shellcheck disable=SC1091
source "$ROOT/scripts/desktop-version-scope.sh"
if desktop_version_diff_affects_app "${LAST_SHA}..${HEAD_SHA}"; then
AFFECTS=1
fi
elif [[ -n "$HEAD_SHA" && -z "$LAST_SHA" ]]; then
PENDING=1
AFFECTS=1
fi
node -e "
const fs=require('fs');
const out=process.argv[1];
const data={
headSha: process.argv[2]||'',
headShort: process.argv[3]||'',
lastBuiltSha: process.argv[4]||'',
publishedVersion: process.argv[5]||'',
pendingCommitCount: parseInt(process.argv[6],10)||0,
affectsDesktop: process.argv[7]==='1',
updatedAt: new Date().toISOString(),
};
fs.writeFileSync(out, JSON.stringify(data,null,2)+'\n');
console.log('[source-state]', data.headShort||'?','pending',data.pendingCommitCount,'affects',data.affectsDesktop);
" "$OUT" "$HEAD_SHA" "$HEAD_SHORT" "$LAST_SHA" "$PUBLISHED_VER" "$PENDING" "$AFFECTS"