From cab5365b1982b7c70d6a39970536ec1bc0abb688 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 15 Jun 2026 15:44:43 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EB=8F=99=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DesktopAppReleaseController.java | 8 + .../dto/DesktopAppBuildReadinessDto.java | 22 +++ .../DesktopAppBuildReadinessService.java | 150 ++++++++++++++++++ .../service/DesktopAppBuildService.java | 64 +++++++- frontend/src/App.css | 38 +++++ frontend/src/components/AppDownloadModal.tsx | 81 ++++++++-- frontend/src/utils/backendApi.ts | 17 ++ scripts/bump-desktop-version-from-release.sh | 7 + scripts/jenkins-desktop-pipeline.sh | 3 + scripts/post-receive | 20 ++- scripts/prepare-desktop-jenkins-build.sh | 41 +++++ scripts/publish-desktop-update.sh | 5 + scripts/trigger-desktop-jenkins-build.sh | 10 ++ scripts/update-desktop-source-state.sh | 56 +++++++ 14 files changed, 499 insertions(+), 23 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/dto/DesktopAppBuildReadinessDto.java create mode 100644 backend/src/main/java/com/goldenchart/service/DesktopAppBuildReadinessService.java create mode 100755 scripts/prepare-desktop-jenkins-build.sh create mode 100755 scripts/update-desktop-source-state.sh diff --git a/backend/src/main/java/com/goldenchart/controller/DesktopAppReleaseController.java b/backend/src/main/java/com/goldenchart/controller/DesktopAppReleaseController.java index d0f68d0..dbf85aa 100644 --- a/backend/src/main/java/com/goldenchart/controller/DesktopAppReleaseController.java +++ b/backend/src/main/java/com/goldenchart/controller/DesktopAppReleaseController.java @@ -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) { diff --git a/backend/src/main/java/com/goldenchart/dto/DesktopAppBuildReadinessDto.java b/backend/src/main/java/com/goldenchart/dto/DesktopAppBuildReadinessDto.java new file mode 100644 index 0000000..c9ffcac --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/DesktopAppBuildReadinessDto.java @@ -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; +} diff --git a/backend/src/main/java/com/goldenchart/service/DesktopAppBuildReadinessService.java b/backend/src/main/java/com/goldenchart/service/DesktopAppBuildReadinessService.java new file mode 100644 index 0000000..4f4dbfb --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/DesktopAppBuildReadinessService.java @@ -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; + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java b/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java index c74a21e..fb956f7 100644 --- a/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java +++ b/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java @@ -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(); diff --git a/frontend/src/App.css b/frontend/src/App.css index 6eb6557..097b768 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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: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-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 { margin: -8px 0 12px; font-size: 12px; color: var(--text2); line-height: 1.45; } diff --git a/frontend/src/components/AppDownloadModal.tsx b/frontend/src/components/AppDownloadModal.tsx index f1f01ca..f1c93cf 100644 --- a/frontend/src/components/AppDownloadModal.tsx +++ b/frontend/src/components/AppDownloadModal.tsx @@ -1,14 +1,16 @@ /** * 모바일·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 DraggableModalFrame from './DraggableModalFrame'; import { + loadDesktopAppBuildReadiness, loadDesktopAppBuildStatus, loadDesktopAppReleaseInfo, loadMobileAppReleaseInfo, triggerDesktopAppBuild, + type DesktopAppBuildReadinessDto, type DesktopAppBuildStatusDto, type DesktopAppPlatformReleaseDto, type DesktopAppReleaseInfoDto, @@ -351,12 +353,14 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) const [info, setInfo] = useState(null); const [mobileInfo, setMobileInfo] = useState(null); const [buildStatus, setBuildStatus] = useState(null); + const [readiness, setReadiness] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [buildBusy, setBuildBusy] = useState(false); const [buildHint, setBuildHint] = useState(null); const [buildTracking, setBuildTracking] = useState(false); const [elapsedTick, setElapsedTick] = useState(0); + const autoTrackStarted = useRef(false); const reloadRelease = useCallback(async () => { const [desktop, mobile] = await Promise.all([ @@ -374,19 +378,28 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) return st; }, []); + const reloadReadiness = useCallback(async () => { + const rd = await loadDesktopAppBuildReadiness(); + setReadiness(rd); + return rd; + }, []); + useEffect(() => { let cancelled = false; - void Promise.all([reloadRelease(), reloadBuildStatus()]) + void Promise.all([reloadRelease(), reloadBuildStatus(), reloadReadiness()]) .catch(() => { if (!cancelled) setError('PC 프로그램 정보를 불러오지 못했습니다.'); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [reloadRelease, reloadBuildStatus]); + }, [reloadRelease, reloadBuildStatus, reloadReadiness]); const isBuilding = Boolean(buildStatus?.building || buildStatus?.status === 'queued'); - const showBuildProgress = Boolean( - buildBusy || buildTracking || isBuilding - || (buildStatus?.buildNumber && buildStatus.status !== 'idle' && buildStatus.status !== 'ready'), - ); + const showBuildProgress = Boolean(buildBusy || buildTracking || isBuilding); + + useEffect(() => { + if (loading || autoTrackStarted.current || !isBuilding) return; + autoTrackStarted.current = true; + setBuildTracking(true); + }, [loading, isBuilding]); useEffect(() => { if (!showBuildProgress || buildStatus?.startedAtMs == null) return; @@ -401,27 +414,38 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) if (!st) return; if (isTerminalBuildStatus(st.status, st.building)) { setBuildTracking(false); - void reloadRelease(); - } else if (!st.building && st.status !== 'queued') { - void reloadRelease(); + void Promise.all([reloadRelease(), reloadReadiness()]).then(([desktop]) => { + if (st.status === 'success' && readiness?.nextBuildVersion && desktop?.version) { + 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(); const timer = window.setInterval(poll, BUILD_POLL_MS); return () => window.clearInterval(timer); - }, [buildTracking, isBuilding, buildBusy, reloadBuildStatus, reloadRelease]); + }, [buildTracking, isBuilding, buildBusy, reloadBuildStatus, reloadRelease, reloadReadiness, readiness?.nextBuildVersion]); const handleBuild = useCallback(async () => { if (!canBuildDesktop || buildBusy || isBuilding) return; setBuildBusy(true); setBuildTracking(true); - setBuildHint('Jenkins 빌드 요청 중…'); + const nextVer = readiness?.nextBuildVersion; + setBuildHint(nextVer + ? `Jenkins 빌드 요청 중… (예상 v${nextVer})` + : 'Jenkins 빌드 요청 중…'); try { const res = await triggerDesktopAppBuild(); setBuildHint(res.message ?? (res.accepted ? '빌드 시작됨' : '빌드 요청 거부')); if (!res.accepted) setBuildTracking(false); - await reloadBuildStatus(); + await Promise.all([reloadBuildStatus(), reloadReadiness()]); if (res.accepted) { window.setTimeout(() => { void reloadBuildStatus(); }, 2000); } @@ -431,7 +455,7 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) } finally { setBuildBusy(false); } - }, [canBuildDesktop, buildBusy, isBuilding, reloadBuildStatus]); + }, [canBuildDesktop, buildBusy, isBuilding, reloadBuildStatus, reloadReadiness, readiness?.nextBuildVersion]); 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

PC 프로그램 정보 불러오는 중…

; if (error) return

{error}

; - const statusMessage = !showBuildProgress ? (buildHint ?? buildStatus?.message) : null; + const statusMessage = !showBuildProgress ? (buildHint ?? (readiness?.status === 'up_to_date' ? readiness.message : buildStatus?.message)) : null; return ( <> @@ -467,7 +502,9 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) className={`app-download-build-btn${buildBusy || isBuilding ? ' busy' : ''}`} onClick={() => { void handleBuild(); }} disabled={buildBusy || isBuilding} - title="Git main 최신 소스로 macOS·Windows 데스크톱 앱 Jenkins 빌드" + title={readiness?.buildRequired + ? `다음 빌드 예상 버전 v${readiness.nextBuildVersion ?? '?'}` + : '현재 최신 소스가 PC 빌드에 반영되어 있습니다'} aria-label="데스크톱 앱 빌드" > @@ -476,6 +513,18 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) )} + {readiness && readinessLabel && ( +
+ {readinessLabel} +

{readiness.message}

+ {readiness.publishedVersion && readiness.nextBuildVersion && readiness.buildRequired && ( +

+ 현재 v{readiness.publishedVersion} → 빌드 후 v{readiness.nextBuildVersion} +

+ )} +
+ )} + {desktopVersionMismatch && (

macOS·Windows 설치 파일 버전이 최신 빌드(v{info?.version})와 다릅니다. 「앱 빌드」를 다시 실행하세요. diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index a91e7f6..5c60d2c 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -719,6 +719,23 @@ export async function loadDesktopAppBuildStatus(): Promise('/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 { + return request('/desktop-app/build/readiness'); +} + export async function triggerDesktopAppBuild(): Promise { return requestOrThrow('/desktop-app/build', { method: 'POST' }); } diff --git a/scripts/bump-desktop-version-from-release.sh b/scripts/bump-desktop-version-from-release.sh index 80d25f8..357721c 100755 --- a/scripts/bump-desktop-version-from-release.sh +++ b/scripts/bump-desktop-version-from-release.sh @@ -74,6 +74,13 @@ if (releaseDir && fs.existsSync(releaseDir)) { const m = name.match(/GoldenChart[_-](\\d+\\.\\d+\\.\\d+)/i); 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); diff --git a/scripts/jenkins-desktop-pipeline.sh b/scripts/jenkins-desktop-pipeline.sh index a02af86..a6a1748 100755 --- a/scripts/jenkins-desktop-pipeline.sh +++ b/scripts/jenkins-desktop-pipeline.sh @@ -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 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 로 동기 (구 스크립트 방지) if [[ -n "${WORKSPACE:-}" && -d "${WORKSPACE}/scripts" && "${WORKSPACE}" != "$WORK_TREE" ]]; then log "Sync scripts: ${WORKSPACE}/scripts → ${WORK_TREE}/scripts" diff --git a/scripts/post-receive b/scripts/post-receive index d81fa19..314308e 100755 --- a/scripts/post-receive +++ b/scripts/post-receive @@ -49,17 +49,31 @@ while read -r OLDREV NEWREV REFNAME; do WORK_TREE="$WORK_TREE" "$RELOAD" >> "$DEPLOY_LOG" 2>&1 || true 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}" 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 빌드 트리거" if "$TRIGGER" >> "$DEPLOY_LOG" 2>&1; then echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG Desktop 빌드 큐 등록 완료" else echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG [WARN] Desktop Jenkins 트리거 실패 (웹 배포는 성공)" 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" fi diff --git a/scripts/prepare-desktop-jenkins-build.sh b/scripts/prepare-desktop-jenkins-build.sh new file mode 100755 index 0000000..5814675 --- /dev/null +++ b/scripts/prepare-desktop-jenkins-build.sh @@ -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" diff --git a/scripts/publish-desktop-update.sh b/scripts/publish-desktop-update.sh index 006eaed..baf3568 100755 --- a/scripts/publish-desktop-update.sh +++ b/scripts/publish-desktop-update.sh @@ -180,4 +180,9 @@ log "Published v${VERSION}:" ls -la "$PUBLISH_UPDATES" 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." diff --git a/scripts/trigger-desktop-jenkins-build.sh b/scripts/trigger-desktop-jenkins-build.sh index 3908e64..b3d10a1 100755 --- a/scripts/trigger-desktop-jenkins-build.sh +++ b/scripts/trigger-desktop-jenkins-build.sh @@ -56,6 +56,16 @@ if curl_get "${JOB_API}/lastBuild/api/json?tree=building" 2>/dev/null | grep -q exit 0 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() { python3 -c "import sys,json; print(json.load(sys.stdin).get('crumbRequestField',''))" 2>/dev/null || true } diff --git a/scripts/update-desktop-source-state.sh b/scripts/update-desktop-source-state.sh new file mode 100755 index 0000000..fc0a013 --- /dev/null +++ b/scripts/update-desktop-source-state.sh @@ -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"