promote verified develop builds to release
This commit is contained in:
+166
-1
@@ -30,6 +30,8 @@ const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppsService {
|
export class AppsService {
|
||||||
|
private giteaUsername?: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@@ -288,7 +290,23 @@ export class AppsService {
|
|||||||
throw new BadRequestException('성공한 빌드 작업만 릴리즈에 포함할 수 있습니다.');
|
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 commitShas = [...new Set(buildJobs.map((job) => job.commitSha).filter(Boolean))];
|
||||||
const artifact = buildJobs.find((job) => job.artifact)?.artifact ?? null;
|
const artifact = buildJobs.find((job) => job.artifact)?.artifact ?? null;
|
||||||
const imageTags = createReleaseImageTags(buildJobs, app.components);
|
const imageTags = createReleaseImageTags(buildJobs, app.components);
|
||||||
@@ -309,6 +327,143 @@ export class AppsService {
|
|||||||
return { release };
|
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) {
|
listDeployments(appKey?: string) {
|
||||||
return this.prisma.deploymentJob.findMany({
|
return this.prisma.deploymentJob.findMany({
|
||||||
where: appKey ? { app: { key: appKey } } : undefined,
|
where: appKey ? { app: { key: appKey } } : undefined,
|
||||||
@@ -2314,6 +2469,16 @@ function parseReleaseImageTags(value: Prisma.JsonValue | null) {
|
|||||||
return { images };
|
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(
|
function createReleaseImageTags(
|
||||||
buildJobs: Array<{
|
buildJobs: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -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 { createHash } from 'node:crypto';
|
||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -17,6 +18,8 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
|||||||
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
type BuildJobWithComponent = BuildJob & {
|
type BuildJobWithComponent = BuildJob & {
|
||||||
component: AppComponent & {
|
component: AppComponent & {
|
||||||
app: App;
|
app: App;
|
||||||
@@ -237,6 +240,29 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
onProcessEnd: () => this.activeProcesses.delete(job.id),
|
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(
|
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({
|
async function runCommand({
|
||||||
jobId,
|
jobId,
|
||||||
command,
|
command,
|
||||||
|
|||||||
Reference in New Issue
Block a user