From 902817788db32cce6f9f3048fe23aca3c7189412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Thu, 11 Jun 2026 21:27:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - projects: 为环境补充 releasePolicy 和 refPattern,区分 branch、tag、master 发布策略 - deploy-runs: 创建发布单前校验项目状态、环境和 ref 规则 - repository: 数据库读取路径补齐发布策略推断 --- .../deploy-execution.service.spec.ts | 2 + src/deploy-runs/deploy-runs.service.spec.ts | 147 ++++++++++++++++-- src/deploy-runs/deploy-runs.service.ts | 69 ++++++++ src/projects/project.repository.ts | 28 ++++ src/projects/project.seed.ts | 20 +++ src/projects/project.types.ts | 2 + 6 files changed, 251 insertions(+), 17 deletions(-) diff --git a/src/deploy-runs/deploy-execution.service.spec.ts b/src/deploy-runs/deploy-execution.service.spec.ts index b11abfd..90196ca 100644 --- a/src/deploy-runs/deploy-execution.service.spec.ts +++ b/src/deploy-runs/deploy-execution.service.spec.ts @@ -19,6 +19,8 @@ describe('DeployExecutionService', () => { name: 'test', displayName: '测试环境', jenkinsJobPath: 'my-project-dev/test-access-manage-develop', + releasePolicy: 'branch', + refPattern: '^(develop|feature/.+|hotfix/.+)$', }, ], }; diff --git a/src/deploy-runs/deploy-runs.service.spec.ts b/src/deploy-runs/deploy-runs.service.spec.ts index 610a3e4..5723ed2 100644 --- a/src/deploy-runs/deploy-runs.service.spec.ts +++ b/src/deploy-runs/deploy-runs.service.spec.ts @@ -1,6 +1,7 @@ import { AuditService } from '../audit/audit.service'; import { AppError } from '../common/errors/app-error'; import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service'; +import { ProjectSummary } from '../projects/project.types'; import { ProjectsService } from '../projects/projects.service'; import { DeployExecutionService } from './deploy-execution.service'; import { DeployRunRepository } from './deploy-run.repository'; @@ -27,8 +28,30 @@ describe('DeployRunsService', () => { steps: [], }; - function createService(options?: { runs?: DeployRunSummary[] }) { + const project: ProjectSummary = { + id: 'project_001', + key: 'access-manage', + name: 'access-manage', + repositoryUrl: 'https://gitea.ops.mrzhan.top/my-project/access-manage.git', + defaultBranch: 'develop', + status: 'active', + environments: [ + { + name: 'test', + displayName: '测试环境', + jenkinsJobPath: 'my-project-dev/test-access-manage-develop', + releasePolicy: 'branch', + refPattern: '^(develop|feature/.+|hotfix/.+)$', + }, + ], + }; + + function createService(options?: { + runs?: DeployRunSummary[]; + project?: ProjectSummary; + }) { const runs = options?.runs ?? [baseRun]; + const resolvedProject = options?.project ?? project; const repository = { findMany: jest.fn(() => Promise.resolve(runs)), findById: jest.fn((id: string) => @@ -52,22 +75,7 @@ describe('DeployRunsService', () => { >; const projects = { getProject: jest.fn(() => - Promise.resolve({ - id: 'project_001', - key: 'access-manage', - name: 'access-manage', - repositoryUrl: - 'https://gitea.ops.mrzhan.top/my-project/access-manage.git', - defaultBranch: 'develop', - status: 'active' as const, - environments: [ - { - name: 'test' as const, - displayName: '测试环境', - jenkinsJobPath: 'my-project-dev/test-access-manage-develop', - }, - ], - }), + Promise.resolve(resolvedProject), ), } satisfies Pick; const audit = { @@ -149,6 +157,111 @@ describe('DeployRunsService', () => { expect(execution.execute).not.toHaveBeenCalled(); }); + it('rejects project environments that are not configured before creating a run', async () => { + const { audit, execution, repository, service } = createService({ + project: { + ...project, + key: 'devops-platform-web', + defaultBranch: 'master', + environments: [ + { + name: 'production', + displayName: '生产环境', + jenkinsJobPath: 'devops-platform/prod-devops-platform-web-master', + releasePolicy: 'master', + refPattern: '^master$', + }, + ], + }, + }); + + await expect( + service.createRun({ + projectKey: 'devops-platform-web', + environment: 'test', + ref: 'master', + operator: 'ops-admin', + }), + ).rejects.toBeInstanceOf(AppError); + expect(repository.create).not.toHaveBeenCalled(); + expect(execution.execute).not.toHaveBeenCalled(); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'DEPLOY_REJECTED', + after: expect.objectContaining({ + reason: 'Environment is not configured', + }), + }), + ); + }); + + it('rejects production refs that do not match tag release policy', async () => { + const { audit, execution, repository, service } = createService({ + project: { + ...project, + environments: [ + { + name: 'production', + displayName: '生产环境', + jenkinsJobPath: 'my-project-prod/prod-access-manage-tag', + releasePolicy: 'tag', + refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$', + }, + ], + }, + }); + + await expect( + service.createRun({ + projectKey: 'access-manage', + environment: 'production', + ref: 'master', + operator: 'ops-admin', + }), + ).rejects.toBeInstanceOf(AppError); + expect(repository.create).not.toHaveBeenCalled(); + expect(execution.execute).not.toHaveBeenCalled(); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'DEPLOY_REJECTED', + after: expect.objectContaining({ + reason: 'Ref does not match release policy', + }), + }), + ); + }); + + it('allows master-only production releases for self-managed platform projects', async () => { + const { execution, repository, service } = createService({ + project: { + ...project, + key: 'devops-platform-web', + defaultBranch: 'master', + environments: [ + { + name: 'production', + displayName: '生产环境', + jenkinsJobPath: 'devops-platform/prod-devops-platform-web-master', + releasePolicy: 'master', + refPattern: '^master$', + }, + ], + }, + }); + + const run = await service.createRun({ + projectKey: 'devops-platform-web', + environment: 'production', + ref: 'master', + operator: 'ops-admin', + idempotencyKey: 'self-managed-master', + }); + + expect(run.id).toBe('self-managed-master'); + expect(repository.create).toHaveBeenCalled(); + expect(execution.execute).toHaveBeenCalled(); + }); + it('cancels an existing deploy run', async () => { const { audit, service } = createService(); diff --git a/src/deploy-runs/deploy-runs.service.ts b/src/deploy-runs/deploy-runs.service.ts index 79863a4..3276451 100644 --- a/src/deploy-runs/deploy-runs.service.ts +++ b/src/deploy-runs/deploy-runs.service.ts @@ -6,6 +6,10 @@ import { DeployNotificationEvent, DeployNotificationService, } from '../notifications/wecom/deploy-notification.service'; +import { + ProjectEnvironmentSummary, + ProjectSummary, +} from '../projects/project.types'; import { ProjectsService } from '../projects/projects.service'; import { DeployExecutionService } from './deploy-execution.service'; import { DeployRunRepository } from './deploy-run.repository'; @@ -57,6 +61,7 @@ export class DeployRunsService { } const project = await this.projectsService.getProject(input.projectKey); + this.validateReleaseRequest(input, project); const run = this.deployRunRepository.create(input); this.auditService.record({ @@ -189,6 +194,70 @@ export class DeployRunsService { ); } + private validateReleaseRequest( + input: CreateDeployRunInput, + project: ProjectSummary, + ): ProjectEnvironmentSummary { + if (project.status !== 'active') { + this.recordDeployRejected(input, 'Project is archived'); + throw new AppError( + 'VALIDATION_FAILED', + `Project ${project.key} is archived and cannot be deployed`, + 400, + { projectKey: project.key }, + ); + } + + const environment = project.environments.find( + (item) => item.name === input.environment, + ); + + if (!environment) { + this.recordDeployRejected(input, 'Environment is not configured'); + throw new AppError( + 'VALIDATION_FAILED', + `Project ${project.key} does not expose ${input.environment} environment`, + 400, + { projectKey: project.key, environment: input.environment }, + ); + } + + if (!new RegExp(environment.refPattern).test(input.ref)) { + this.recordDeployRejected(input, 'Ref does not match release policy'); + throw new AppError( + 'VALIDATION_FAILED', + `Ref ${input.ref} does not match ${environment.releasePolicy} release policy`, + 400, + { + projectKey: project.key, + environment: input.environment, + ref: input.ref, + releasePolicy: environment.releasePolicy, + refPattern: environment.refPattern, + }, + ); + } + + return environment; + } + + private recordDeployRejected( + input: CreateDeployRunInput, + reason: string, + ): void { + this.auditService.record({ + action: 'DEPLOY_REJECTED', + resourceType: 'deploy_run', + actorName: input.operator, + after: { + projectKey: input.projectKey, + environment: input.environment, + ref: input.ref, + reason, + }, + }); + } + private toSafeSyncError(error: unknown): string { const message = error instanceof Error ? error.message : 'Jenkins sync failed'; diff --git a/src/projects/project.repository.ts b/src/projects/project.repository.ts index 5f98049..818c5b7 100644 --- a/src/projects/project.repository.ts +++ b/src/projects/project.repository.ts @@ -42,6 +42,10 @@ export class ProjectRepository { name: environment.name === 'TEST' ? 'test' : 'production', displayName: environment.displayName, jenkinsJobPath: environment.deployJobs[0]?.jenkinsJobPath ?? '', + ...this.resolveReleasePolicy( + environment.name === 'TEST' ? 'test' : 'production', + environment.deployJobs[0]?.jenkinsJobPath ?? '', + ), })), })); } @@ -57,4 +61,28 @@ export class ProjectRepository { Boolean(this.config.get('DATABASE_URL', { infer: true })) ); } + + private resolveReleasePolicy( + environment: 'test' | 'production', + jobPath: string, + ): Pick { + if (environment === 'test') { + return { + releasePolicy: 'branch', + refPattern: '^(develop|feature/.+|hotfix/.+)$', + }; + } + + if (jobPath.endsWith('-master')) { + return { + releasePolicy: 'master', + refPattern: '^master$', + }; + } + + return { + releasePolicy: 'tag', + refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$', + }; + } } diff --git a/src/projects/project.seed.ts b/src/projects/project.seed.ts index d8e2f31..a73ec08 100644 --- a/src/projects/project.seed.ts +++ b/src/projects/project.seed.ts @@ -13,11 +13,15 @@ export const seedProjects: ProjectSummary[] = [ name: 'test', displayName: '测试环境', jenkinsJobPath: 'my-project-dev/test-access-manage-develop', + releasePolicy: 'branch', + refPattern: '^(develop|feature/.+|hotfix/.+)$', }, { name: 'production', displayName: '生产环境', jenkinsJobPath: 'my-project-prod/prod-access-manage-tag', + releasePolicy: 'tag', + refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$', }, ], }, @@ -33,11 +37,15 @@ export const seedProjects: ProjectSummary[] = [ name: 'test', displayName: '测试环境', jenkinsJobPath: 'my-project-dev/test-role-admin-develop', + releasePolicy: 'branch', + refPattern: '^(develop|feature/.+|hotfix/.+)$', }, { name: 'production', displayName: '生产环境', jenkinsJobPath: 'my-project-prod/prod-role-admin-tag', + releasePolicy: 'tag', + refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$', }, ], }, @@ -53,11 +61,15 @@ export const seedProjects: ProjectSummary[] = [ name: 'test', displayName: '测试环境', jenkinsJobPath: 'my-project-dev/test-role-user-develop', + releasePolicy: 'branch', + refPattern: '^(develop|feature/.+|hotfix/.+)$', }, { name: 'production', displayName: '生产环境', jenkinsJobPath: 'my-project-prod/prod-role-user-tag', + releasePolicy: 'tag', + refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$', }, ], }, @@ -73,11 +85,15 @@ export const seedProjects: ProjectSummary[] = [ name: 'test', displayName: '测试环境', jenkinsJobPath: 'my-project-dev/test-my-resume-develop', + releasePolicy: 'branch', + refPattern: '^(master|develop|feature/.+|hotfix/.+)$', }, { name: 'production', displayName: '生产环境', jenkinsJobPath: 'my-project-prod/prod-my-resume-tag', + releasePolicy: 'tag', + refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$', }, ], }, @@ -93,6 +109,8 @@ export const seedProjects: ProjectSummary[] = [ name: 'production', displayName: '生产环境', jenkinsJobPath: 'devops-platform/prod-devops-platform-api-master', + releasePolicy: 'master', + refPattern: '^master$', }, ], }, @@ -108,6 +126,8 @@ export const seedProjects: ProjectSummary[] = [ name: 'production', displayName: '生产环境', jenkinsJobPath: 'devops-platform/prod-devops-platform-web-master', + releasePolicy: 'master', + refPattern: '^master$', }, ], }, diff --git a/src/projects/project.types.ts b/src/projects/project.types.ts index e0e3d14..54bc857 100644 --- a/src/projects/project.types.ts +++ b/src/projects/project.types.ts @@ -2,6 +2,8 @@ export type ProjectEnvironmentSummary = { name: 'test' | 'production'; displayName: string; jenkinsJobPath: string; + releasePolicy: 'branch' | 'tag' | 'master'; + refPattern: string; }; export type ProjectSummary = {