build app releases from release branch
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-06 16:31:35 +09:00
parent 855606a215
commit 7ca622af4f
+143 -8
View File
@@ -305,28 +305,159 @@ export class AppsService {
throw new BadRequestException('앱 릴리즈 대상 브랜치는 release만 사용할 수 있습니다.'); throw new BadRequestException('앱 릴리즈 대상 브랜치는 release만 사용할 수 있습니다.');
} }
await this.promoteReleaseBranches(buildJobs, 'develop', branch);
const commitShas = [...new Set(buildJobs.map((job) => job.commitSha).filter(Boolean))]; 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({ const release = await this.prisma.appRelease.create({
data: { data: {
appId: body.appId, appId: body.appId,
version: body.version.trim(), version: body.version.trim(),
branch, branch,
commitSha: commitShas.length === 1 ? commitShas[0] : null, commitSha: commitShas.length === 1 ? commitShas[0] : null,
artifact, artifact: null,
imageTags, imageTags: {
status: 'SUCCEEDED', promotion: {
sourceBranch: 'develop',
targetBranch: branch,
sourceBuildJobIds: buildJobs.map((job) => job.id),
},
},
status: 'PENDING',
}, },
include: { app: true }, include: { app: true },
}); });
void this.finalizeReleaseFromBuilds(release.id, buildJobs.map((job) => job.id));
return { release }; 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<string>('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<string>(
'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<string>('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( private async promoteReleaseBranches(
buildJobs: Array<{ buildJobs: Array<{
@@ -2624,3 +2755,7 @@ function getBuildPlanLogs(input: {
return lines; return lines;
} }
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}