diff --git a/.env.example b/.env.example index c9995f0..7ae0494 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,8 @@ GITEA_APP_ORG=platform GITEA_ADMIN_TOKEN= GITEA_RELEASE_PUSH_WHITELIST= GITEA_RELEASE_MERGE_WHITELIST= +GITEA_RELEASE_PUSH_WHITELIST_TEAMS= +GITEA_RELEASE_MERGE_WHITELIST_TEAMS= BUILD_REGISTRY=localhost:5000 BUILD_ARTIFACT_INSTALL_COMMAND=npm ci LAUNCHER_BUILD_COMMAND="npm ci && npm run build:linux" diff --git a/src/apps/apps.service.ts b/src/apps/apps.service.ts index 5912e1a..2947fdf 100644 --- a/src/apps/apps.service.ts +++ b/src/apps/apps.service.ts @@ -640,6 +640,11 @@ export class AppsService { if (byRepository.size === 0) return; + const gitBin = this.configService.get('GIT_BIN', 'git'); + const isUnrelatedHistoryError = (message: string) => { + const normalized = message.toLowerCase(); + return normalized.includes('refusing to merge unrelated histories') || normalized.includes('not possible to fast-forward'); + }; const workRoot = path.join(os.tmpdir(), 'studio-admin-release-promotions'); await fs.mkdir(workRoot, { recursive: true }); @@ -647,38 +652,59 @@ export class AppsService { const workDir = path.join(workRoot, `${sanitizeImageName(repository)}-${Date.now()}`); try { await fs.rm(workDir, { recursive: true, force: true }); - await execFileAsync(this.configService.get('GIT_BIN', 'git'), [ + await execFileAsync(gitBin, [ 'clone', '--no-checkout', await this.getAuthenticatedHttpCloneUrl(repository), workDir, ]); - await execFileAsync(this.configService.get('GIT_BIN', 'git'), [ + await execFileAsync(gitBin, [ 'fetch', 'origin', sourceBranch, ], { cwd: workDir }); - await execFileAsync(this.configService.get('GIT_BIN', 'git'), [ + await execFileAsync(gitBin, [ 'fetch', 'origin', targetBranch, ], { cwd: workDir }).catch(() => undefined); - const targetExists = await gitRefExists(workDir, `refs/remotes/origin/${targetBranch}`, this.configService.get('GIT_BIN', 'git')); + const pushArgs: string[] = ['push', 'origin', `HEAD:refs/heads/${targetBranch}`]; + + const targetExists = await gitRefExists(workDir, `refs/remotes/origin/${targetBranch}`, gitBin); if (targetExists) { - await execFileAsync(this.configService.get('GIT_BIN', 'git'), [ + await execFileAsync(gitBin, [ 'checkout', '-B', targetBranch, `origin/${targetBranch}`, ], { cwd: workDir }); - await execFileAsync(this.configService.get('GIT_BIN', 'git'), [ - 'merge', - '--ff-only', - commitSha, - ], { cwd: workDir }); + let forcePush = false; + try { + await execFileAsync(gitBin, [ + 'merge', + '--ff-only', + commitSha, + ], { cwd: workDir }); + } catch (error) { + const message = String(error instanceof Error ? error.message : error); + if (!isUnrelatedHistoryError(message)) { + throw error; + } + forcePush = true; + await execFileAsync(gitBin, [ + 'checkout', + '-B', + targetBranch, + commitSha, + ], { cwd: workDir }); + } + + if (forcePush) { + pushArgs.splice(0, pushArgs.length, 'push', '--force-with-lease', 'origin', `HEAD:refs/heads/${targetBranch}`); + } } else { - await execFileAsync(this.configService.get('GIT_BIN', 'git'), [ + await execFileAsync(gitBin, [ 'checkout', '-B', targetBranch, @@ -686,11 +712,7 @@ export class AppsService { ], { cwd: workDir }); } - await execFileAsync(this.configService.get('GIT_BIN', 'git'), [ - 'push', - 'origin', - `HEAD:refs/heads/${targetBranch}`, - ], { cwd: workDir }); + await execFileAsync(gitBin, pushArgs, { cwd: workDir }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown git promotion failure'; throw new BadRequestException(`${repository} release 브랜치 승격 실패: ${message}`); diff --git a/src/gitea/gitea.service.ts b/src/gitea/gitea.service.ts index d537a38..7367df8 100644 --- a/src/gitea/gitea.service.ts +++ b/src/gitea/gitea.service.ts @@ -36,6 +36,8 @@ export class GiteaService { private readonly token?: string; private readonly releaseBranchPushUsers: string[]; private readonly releaseBranchMergeUsers: string[]; + private readonly releaseBranchPushTeams: string[]; + private readonly releaseBranchMergeTeams: string[]; constructor(configService: ConfigService) { this.baseUrl = configService @@ -49,6 +51,12 @@ export class GiteaService { this.releaseBranchMergeUsers = parseWhiteList( configService.get('GITEA_RELEASE_MERGE_WHITELIST'), ); + this.releaseBranchPushTeams = parseWhiteList( + configService.get('GITEA_RELEASE_PUSH_WHITELIST_TEAMS'), + ); + this.releaseBranchMergeTeams = parseWhiteList( + configService.get('GITEA_RELEASE_MERGE_WHITELIST_TEAMS'), + ); } async getVersion() { @@ -258,23 +266,22 @@ export class GiteaService { } private async protectReleaseBranch(owner: string, repo: string) { - const me = await this.getAuthenticatedUser(); - const pushUsernames = uniqueUsers([ - ...this.releaseBranchPushUsers, - ...(me?.login ? [me.login] : []), - ]); - const mergeUsernames = uniqueUsers([ - ...this.releaseBranchMergeUsers, - ...(me?.login ? [me.login] : []), - ]); + const pushUsernames = uniqueUsers(this.releaseBranchPushUsers); + const mergeUsernames = uniqueUsers(this.releaseBranchMergeUsers); + const pushTeams = uniqueUsers(this.releaseBranchPushTeams); + const mergeTeams = uniqueUsers(this.releaseBranchMergeTeams); + const hasPushWhitelist = pushUsernames.length > 0 || pushTeams.length > 0; + const hasMergeWhitelist = mergeUsernames.length > 0 || mergeTeams.length > 0; const body = { branch_name: 'release', enable_push: true, - enable_push_whitelist: true, + enable_push_whitelist: hasPushWhitelist, push_whitelist_usernames: pushUsernames, - enable_merge_whitelist: true, - merge_whitelist_usernames: mergeUsernames, + push_whitelist_teams: pushTeams, + enable_merge_whitelist: hasMergeWhitelist, + merge_whitelist_usernames: hasMergeWhitelist ? mergeUsernames : [], + merge_whitelist_teams: hasMergeWhitelist ? mergeTeams : [], enable_force_push: false, }; @@ -285,14 +292,6 @@ export class GiteaService { }); } - private async getAuthenticatedUser() { - try { - return await this.request<{ login: string }>('/api/v1/user'); - } catch { - return null; - } - } - private async request( path: string, init?: RequestInit & { allowedStatuses?: number[] },