From 7741f02e9b4dc15bc2d0b771e4d52447fa56c152 Mon Sep 17 00:00:00 2001 From: chyoo Date: Mon, 6 Jul 2026 17:16:11 +0900 Subject: [PATCH] track and retry app release promotions --- .../migration.sql | 3 + prisma/schema.prisma | 1 + src/apps/apps.service.ts | 107 +++++++++++++++--- 3 files changed, 95 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/20260706093000_add_app_release_logs/migration.sql diff --git a/prisma/migrations/20260706093000_add_app_release_logs/migration.sql b/prisma/migrations/20260706093000_add_app_release_logs/migration.sql new file mode 100644 index 0000000..f5c72cc --- /dev/null +++ b/prisma/migrations/20260706093000_add_app_release_logs/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "app_releases" ADD COLUMN "logs" TEXT; + +COMMENT ON COLUMN "app_releases"."logs" IS '릴리즈 생성 및 release 빌드 로그'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f02e4f5..a568824 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -221,6 +221,7 @@ model AppRelease { artifact String? imageTags Json? @map("image_tags") status JobStatus @default(PENDING) + logs String? createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) app App @relation(fields: [appId], references: [id], onDelete: Cascade) diff --git a/src/apps/apps.service.ts b/src/apps/apps.service.ts index 8791de1..1e8a13c 100644 --- a/src/apps/apps.service.ts +++ b/src/apps/apps.service.ts @@ -305,25 +305,64 @@ export class AppsService { throw new BadRequestException('앱 릴리즈 대상 브랜치는 release만 사용할 수 있습니다.'); } - const commitShas = [...new Set(buildJobs.map((job) => job.commitSha).filter(Boolean))]; - const release = await this.prisma.appRelease.create({ - data: { - appId: body.appId, - version: body.version.trim(), - branch, - commitSha: commitShas.length === 1 ? commitShas[0] : null, - artifact: null, - imageTags: { - promotion: { - sourceBranch: 'develop', - targetBranch: branch, - sourceBuildJobIds: buildJobs.map((job) => job.id), - }, + const version = body.version.trim(); + if (!version) { + throw new BadRequestException('릴리즈 버전을 입력해야 합니다.'); + } + + const existingRelease = await this.prisma.appRelease.findUnique({ + where: { + appId_version: { + appId: body.appId, + version, }, - status: 'PENDING', }, - include: { app: true }, + select: { + id: true, + status: true, + }, }); + if (existingRelease && !['FAILED', 'CANCELED'].includes(existingRelease.status)) { + throw new BadRequestException( + `이미 같은 버전의 릴리즈가 ${existingRelease.status} 상태로 존재합니다. 새 버전을 입력하세요.`, + ); + } + + const commitShas = [...new Set(buildJobs.map((job) => job.commitSha).filter(Boolean))]; + const releaseLogs = [ + `[queued] release creation: ${app.name} ${version}`, + `[source] develop build jobs: ${buildJobs.map((job) => job.id).join(', ')}`, + `[target] branch ${branch}`, + '[pending] waiting for release worker', + ]; + const releaseData = { + branch, + commitSha: commitShas.length === 1 ? commitShas[0] : null, + artifact: null, + imageTags: { + promotion: { + sourceBranch: 'develop', + targetBranch: branch, + sourceBuildJobIds: buildJobs.map((job) => job.id), + }, + }, + status: 'PENDING' as const, + logs: releaseLogs.join('\n'), + }; + const release = existingRelease + ? await this.prisma.appRelease.update({ + where: { id: existingRelease.id }, + data: releaseData, + include: { app: true }, + }) + : await this.prisma.appRelease.create({ + data: { + appId: body.appId, + version, + ...releaseData, + }, + include: { app: true }, + }); void this.finalizeReleaseFromBuilds(release.id, buildJobs.map((job) => job.id)); @@ -332,6 +371,7 @@ export class AppsService { private async finalizeReleaseFromBuilds(releaseId: string, sourceBuildJobIds: string[]) { try { + await this.appendReleaseLog(releaseId, '[running] release worker started'); const release = await this.prisma.appRelease.findUnique({ where: { id: releaseId }, include: { @@ -342,6 +382,7 @@ export class AppsService { }); if (!release) return; + await this.appendReleaseLog(releaseId, `[target] ${release.app.name} ${release.version} (${release.branch})`); const sourceBuildJobs = await this.prisma.buildJob.findMany({ where: { id: { in: sourceBuildJobIds } }, include: { component: { include: { app: true } } }, @@ -351,18 +392,30 @@ export class AppsService { throw new Error('릴리즈 기준 빌드 작업을 찾을 수 없습니다.'); } + for (const job of sourceBuildJobs) { + await this.appendReleaseLog(releaseId, `[source] ${job.component.name} develop ${job.commitSha?.slice(0, 7) ?? '-'}`); + } + await this.appendReleaseLog(releaseId, `[promote] develop -> ${release.branch}`); await this.promoteReleaseBranches(sourceBuildJobs, 'develop', release.branch); + await this.appendReleaseLog(releaseId, '[promote] completed'); + const releaseBuildJobs = await this.createReleaseBuildJobs(sourceBuildJobs, release.branch); + for (const job of releaseBuildJobs) { + await this.appendReleaseLog(releaseId, `[build] queued ${job.component.name} ${job.branch} ${job.commitSha?.slice(0, 7) ?? '-'}`); + } + await this.appendReleaseLog(releaseId, '[wait] waiting for release build jobs'); 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}`); } + await this.appendReleaseLog(releaseId, '[build] release build jobs completed'); 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.appendReleaseLog(releaseId, '[done] release completed'); await this.prisma.appRelease.update({ where: { id: release.id }, data: { @@ -374,16 +427,34 @@ export class AppsService { }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown release build failure'; + const release = await this.prisma.appRelease.findUnique({ + where: { id: releaseId }, + select: { logs: true }, + }).catch(() => null); await this.prisma.appRelease.update({ where: { id: releaseId }, data: { status: 'FAILED', imageTags: { error: message }, + logs: truncateReleaseLogs(appendLog(release?.logs ?? null, `[failed] ${message}`)), }, }).catch(() => undefined); } } + private async appendReleaseLog(releaseId: string, line: string) { + const release = await this.prisma.appRelease.findUnique({ + where: { id: releaseId }, + select: { logs: true }, + }); + if (!release) return; + + await this.prisma.appRelease.update({ + where: { id: releaseId }, + data: { logs: truncateReleaseLogs(appendLog(release.logs, line)) }, + }); + } + private async createReleaseBuildJobs( sourceBuildJobs: Array<{ componentId: string; @@ -1847,6 +1918,10 @@ function appendLog(logs: string | null, line: string) { return [logs, line].filter(Boolean).join('\n'); } +function truncateReleaseLogs(logs: string) { + return logs.length > 200_000 ? logs.slice(-200_000) : logs; +} + function sanitizeK8sName(value: string) { return value .trim()