track and retry app release promotions
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-06 17:16:11 +09:00
parent 7ca622af4f
commit 7741f02e9b
3 changed files with 95 additions and 16 deletions
+91 -16
View File
@@ -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()