promote verified develop builds to release

This commit is contained in:
2026-07-03 16:05:59 +09:00
parent dd11e5186a
commit b56485e9ac
2 changed files with 251 additions and 2 deletions
+166 -1
View File
@@ -30,6 +30,8 @@ const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount';
@Injectable()
export class AppsService {
private giteaUsername?: string;
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
@@ -288,7 +290,23 @@ export class AppsService {
throw new BadRequestException('성공한 빌드 작업만 릴리즈에 포함할 수 있습니다.');
}
const branch = body.branch?.trim() || buildJobs[0]?.branch || 'release';
const invalidBranch = buildJobs.find((job) => job.branch !== 'develop');
if (invalidBranch) {
throw new BadRequestException('develop 브랜치에서 성공한 빌드만 릴리즈로 승격할 수 있습니다.');
}
const missingCommit = buildJobs.find((job) => !job.commitSha);
if (missingCommit) {
throw new BadRequestException('실제 커밋 SHA가 기록된 빌드만 릴리즈로 승격할 수 있습니다.');
}
const branch = body.branch?.trim() || 'release';
if (branch !== 'release') {
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);
@@ -309,6 +327,143 @@ export class AppsService {
return { release };
}
private async promoteReleaseBranches(
buildJobs: Array<{
id: string;
branch: string;
commitSha: string | null;
component: {
name: string;
repository: string | null;
};
}>,
sourceBranch: string,
targetBranch: string,
) {
const byRepository = new Map<string, string>();
for (const job of buildJobs) {
const repository = job.component.repository?.trim();
if (!repository || !job.commitSha) continue;
const current = byRepository.get(repository);
if (current && current !== job.commitSha) {
throw new BadRequestException(
`${repository} 저장소에 서로 다른 커밋의 빌드가 선택되었습니다.`,
);
}
byRepository.set(repository, job.commitSha);
}
if (byRepository.size === 0) return;
const workRoot = path.join(os.tmpdir(), 'studio-admin-release-promotions');
await fs.mkdir(workRoot, { recursive: true });
for (const [repository, commitSha] of byRepository) {
const workDir = path.join(workRoot, `${sanitizeImageName(repository)}-${Date.now()}`);
try {
await fs.rm(workDir, { recursive: true, force: true });
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'clone',
'--no-checkout',
await this.getAuthenticatedHttpCloneUrl(repository),
workDir,
]);
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'fetch',
'origin',
sourceBranch,
], { cwd: workDir });
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'fetch',
'origin',
targetBranch,
], { cwd: workDir }).catch(() => undefined);
const targetExists = await gitRefExists(workDir, `refs/remotes/origin/${targetBranch}`, this.configService.get<string>('GIT_BIN', 'git'));
if (targetExists) {
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'checkout',
'-B',
targetBranch,
`origin/${targetBranch}`,
], { cwd: workDir });
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'merge',
'--ff-only',
commitSha,
], { cwd: workDir });
} else {
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'checkout',
'-B',
targetBranch,
commitSha,
], { cwd: workDir });
}
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'push',
'origin',
`HEAD:refs/heads/${targetBranch}`,
], { cwd: workDir });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown git promotion failure';
throw new BadRequestException(`${repository} release 브랜치 승격 실패: ${message}`);
} finally {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
private getHttpCloneUrl(repository: string) {
return `${this.configService
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
.replace(/\/$/, '')}/${repository}.git`;
}
private async getAuthenticatedHttpCloneUrl(repository: string) {
const baseUrl = this.getHttpCloneUrl(repository);
const token = this.configService.get<string>('GITEA_ADMIN_TOKEN');
if (!token) return baseUrl;
const username =
this.configService.get<string>('BUILD_GIT_USERNAME') ??
(await this.getGiteaUsername(token));
const url = new URL(baseUrl);
url.username = username;
url.password = token;
return url.toString();
}
private async getGiteaUsername(token: string) {
if (this.giteaUsername) return this.giteaUsername;
const response = await fetch(
`${this.configService
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
.replace(/\/$/, '')}/api/v1/user`,
{
headers: {
Accept: 'application/json',
Authorization: `token ${token}`,
},
},
);
if (!response.ok) {
throw new BadRequestException(`Gitea 사용자 확인 실패: ${response.status}`);
}
const data = (await response.json()) as { login?: string };
if (!data.login) {
throw new BadRequestException('Gitea 토큰 사용자명을 확인할 수 없습니다.');
}
this.giteaUsername = data.login;
return data.login;
}
listDeployments(appKey?: string) {
return this.prisma.deploymentJob.findMany({
where: appKey ? { app: { key: appKey } } : undefined,
@@ -2314,6 +2469,16 @@ function parseReleaseImageTags(value: Prisma.JsonValue | null) {
return { images };
}
async function gitRefExists(workDir: string, ref: string, gitBin = 'git') {
try {
await execFileAsync(gitBin, ['show-ref', '--verify', '--quiet', ref], { cwd: workDir });
return true;
} catch {
return false;
}
}
function createReleaseImageTags(
buildJobs: Array<{
id: string;
+85 -1
View File
@@ -1,9 +1,10 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { execFile, spawn, type ChildProcess } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { promisify } from 'node:util';
import {
Injectable,
@@ -17,6 +18,8 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { PrismaService } from '../prisma/prisma.service';
const execFileAsync = promisify(execFile);
type BuildJobWithComponent = BuildJob & {
component: AppComponent & {
app: App;
@@ -237,6 +240,29 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy {
onProcessEnd: () => this.activeProcesses.delete(job.id),
});
}
const resolvedCommitSha = await resolveGitHead(workDir, this.configService.get<string>('GIT_BIN', 'git'));
if (resolvedCommitSha && resolvedCommitSha !== job.commitSha) {
const artifact = getBuildArtifact(job.component, job.branch, resolvedCommitSha);
const image = getBuildImage(
job.component,
job.branch,
resolvedCommitSha,
this.configService.get<string>('BUILD_REGISTRY', 'registry.local'),
);
await this.prisma.buildJob.update({
where: { id: job.id },
data: {
commitSha: resolvedCommitSha,
artifact,
image,
},
});
job.commitSha = resolvedCommitSha;
job.artifact = artifact;
job.image = image;
await append(`[checkout] resolved commit ${resolvedCommitSha}`);
}
}
private async executeByComponentType(
@@ -567,6 +593,64 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy {
}
}
async function resolveGitHead(workDir: string, gitBin = 'git') {
const { stdout } = await execFileAsync(gitBin, ['rev-parse', 'HEAD'], { cwd: workDir });
return stdout.trim() || null;
}
function getBuildArtifact(
component: { name: string; type: string },
branch: string,
commitSha: string | null,
) {
const suffix = getBuildSuffix(branch, commitSha);
if (component.type === 'ELECTRON') {
return `${component.name}-${suffix}.AppImage`;
}
if (component.type === 'MIGRATION') {
return `${component.name}:${branch}:migration-check`;
}
return null;
}
function getBuildImage(
component: { name: string; type: string },
branch: string,
commitSha: string | null,
registry: string,
) {
if (component.type === 'ELECTRON' || component.type === 'MIGRATION') {
return null;
}
return `${registry.replace(/\/$/, '')}/${sanitizeImageName(
component.name,
)}:${sanitizeImageTag(getBuildSuffix(branch, commitSha))}`;
}
function getBuildSuffix(branch: string, commitSha: string | null) {
return commitSha ? commitSha.slice(0, 7) : branch;
}
function sanitizeImageName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._/-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function sanitizeImageTag(value: string) {
return value
.trim()
.replace(/[^A-Za-z0-9_.-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
async function runCommand({
jobId,
command,