From 7ca622af4ff616bcd3607cb04e19db42aedd7896 Mon Sep 17 00:00:00 2001 From: chyoo Date: Mon, 6 Jul 2026 16:31:35 +0900 Subject: [PATCH] build app releases from release branch --- src/apps/apps.service.ts | 151 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 143 insertions(+), 8 deletions(-) diff --git a/src/apps/apps.service.ts b/src/apps/apps.service.ts index b396350..8791de1 100644 --- a/src/apps/apps.service.ts +++ b/src/apps/apps.service.ts @@ -305,28 +305,159 @@ export class AppsService { throw new BadRequestException('앱 릴리즈 대상 브랜치는 release만 사용할 수 있습니다.'); } - await this.promoteReleaseBranches(buildJobs, 'develop', branch); - const commitShas = [...new Set(buildJobs.map((job) => job.commitSha).filter(Boolean))]; - const artifact = buildJobs.find((job) => job.artifact)?.artifact ?? null; - const imageTags = createReleaseImageTags(buildJobs, app.components); - const release = await this.prisma.appRelease.create({ data: { appId: body.appId, version: body.version.trim(), branch, commitSha: commitShas.length === 1 ? commitShas[0] : null, - artifact, - imageTags, - status: 'SUCCEEDED', + artifact: null, + imageTags: { + promotion: { + sourceBranch: 'develop', + targetBranch: branch, + sourceBuildJobIds: buildJobs.map((job) => job.id), + }, + }, + status: 'PENDING', }, include: { app: true }, }); + void this.finalizeReleaseFromBuilds(release.id, buildJobs.map((job) => job.id)); + return { release }; } + private async finalizeReleaseFromBuilds(releaseId: string, sourceBuildJobIds: string[]) { + try { + const release = await this.prisma.appRelease.findUnique({ + where: { id: releaseId }, + include: { + app: { + include: { components: { orderBy: { name: 'asc' } } }, + }, + }, + }); + if (!release) return; + + const sourceBuildJobs = await this.prisma.buildJob.findMany({ + where: { id: { in: sourceBuildJobIds } }, + include: { component: { include: { app: true } } }, + orderBy: { createdAt: 'desc' }, + }); + if (sourceBuildJobs.length !== sourceBuildJobIds.length) { + throw new Error('릴리즈 기준 빌드 작업을 찾을 수 없습니다.'); + } + + await this.promoteReleaseBranches(sourceBuildJobs, 'develop', release.branch); + const releaseBuildJobs = await this.createReleaseBuildJobs(sourceBuildJobs, release.branch); + const completedBuildJobs = await this.waitForBuildJobs(releaseBuildJobs.map((job) => job.id)); + const failed = completedBuildJobs.find((job) => job.status !== 'SUCCEEDED'); + if (failed) { + throw new Error(`${failed.component.name} release 빌드 실패: ${failed.status}`); + } + + const commitShas = [...new Set(completedBuildJobs.map((job) => job.commitSha).filter(Boolean))]; + const artifact = completedBuildJobs.find((job) => job.artifact)?.artifact ?? null; + const imageTags = createReleaseImageTags(completedBuildJobs, release.app.components); + + await this.prisma.appRelease.update({ + where: { id: release.id }, + data: { + commitSha: commitShas.length === 1 ? commitShas[0] : release.commitSha, + artifact, + imageTags, + status: 'SUCCEEDED', + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown release build failure'; + await this.prisma.appRelease.update({ + where: { id: releaseId }, + data: { + status: 'FAILED', + imageTags: { error: message }, + }, + }).catch(() => undefined); + } + } + + private async createReleaseBuildJobs( + sourceBuildJobs: Array<{ + componentId: string; + commitSha: string | null; + component: { + id: string; + name: string; + type: string; + repository: string | null; + buildTarget: string | null; + app: { name: string }; + }; + }>, + branch: string, + ) { + const jobs = []; + for (const sourceJob of sourceBuildJobs) { + const component = sourceJob.component; + const artifact = getBuildArtifact(component, branch, sourceJob.commitSha); + const image = getBuildImage( + component, + branch, + sourceJob.commitSha, + this.configService.get('BUILD_REGISTRY', 'registry.local'), + ); + const job = await this.prisma.buildJob.create({ + data: { + componentId: component.id, + branch, + commitSha: sourceJob.commitSha, + artifact, + image, + status: 'PENDING', + logs: getBuildPlanLogs({ + appName: component.app.name, + componentName: component.name, + componentType: component.type, + repository: component.repository || '', + branch, + commitSha: sourceJob.commitSha, + buildTarget: component.buildTarget, + artifact, + image, + giteaBaseUrl: this.configService.get( + 'GITEA_BASE_URL', + 'http://localhost:3000', + ), + }).join('\n'), + }, + include: { component: true }, + }); + jobs.push(job); + } + return jobs; + } + + private async waitForBuildJobs(buildJobIds: string[]) { + const timeoutMs = Number(this.configService.get('RELEASE_BUILD_TIMEOUT_MS', '1800000')); + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const jobs = await this.prisma.buildJob.findMany({ + where: { id: { in: buildJobIds } }, + include: { component: true }, + }); + if (jobs.length === buildJobIds.length && jobs.every((job) => ['SUCCEEDED', 'FAILED', 'CANCELED'].includes(job.status))) { + return jobs; + } + await delay(3000); + } + + throw new Error('release 빌드 완료 대기 시간이 초과되었습니다.'); + } + private async promoteReleaseBranches( buildJobs: Array<{ @@ -2624,3 +2755,7 @@ function getBuildPlanLogs(input: { return lines; } + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +}