diff --git a/src/core/applications.ts b/src/core/applications.ts index ed06501..e79fe78 100644 --- a/src/core/applications.ts +++ b/src/core/applications.ts @@ -14,6 +14,7 @@ import { archiveApplicationEffect as archiveAppServiceEffect, createApplicationEffect as createAppServiceEffect, createReleaseEffect as createReleaseServiceEffect, + activateReleaseEffect as activateReleaseServiceEffect, disableApplicationEffect as disableAppServiceEffect, enableApplicationEffect as enableAppServiceEffect, getApplicationAndLatestReleaseEffect as getAppAndReleaseServiceEffect, @@ -377,6 +378,18 @@ interface CreateReleaseResult { }; } +interface ActivateReleaseResult { + activateRelease: { + id: string; + version: string; + description?: string; + status: string; + activatedAt?: string; + createdAt: string; + updatedAt: string; + }; +} + interface AppListResult { applications: { edges: Array<{ @@ -468,6 +481,54 @@ function callCreateRelease( ); } +function callActivateRelease( + applicationId: string, + releaseId: string, + opts: Parameters[2], +) { + return narrowResult( + activateReleaseServiceEffect(applicationId, releaseId, opts), + (r) => r as ActivateReleaseResult, + ); +} + +/** + * Activate the release, then set the parent application status to ACTIVE. + * Deploy must activate the release before promoting the application. + */ +function finalizeDeployActivation( + applicationId: string, + releaseId: string, + accessToken: string, + options?: DeployOptions, +): Effect.Effect { + return Effect.gen(function* () { + yield* emitProgress(options, { + type: "step", + name: "release.activate", + status: "started", + }); + yield* callActivateRelease(applicationId, releaseId, { accessToken }); + yield* emitProgress(options, { + type: "step", + name: "release.activate", + status: "completed", + }); + + yield* emitProgress(options, { + type: "step", + name: "application.activate", + status: "started", + }); + yield* callUpdateApp(applicationId, { status: "ACTIVE" }, { accessToken }); + yield* emitProgress(options, { + type: "step", + name: "application.activate", + status: "completed", + }); + }); +} + // --------------------------------------------------------------------------- // Public Effect-first API // --------------------------------------------------------------------------- @@ -939,7 +1000,8 @@ export function applicationReleaseEffect( * 6. Post-bundle security scan (Phase 2.5) * 7. Get presigned upload URLs (Phase 3) * 8. Upload artifacts to S3 (Phase 4) - * 9. Update application status to ACTIVE + * 9. Activate the release (activateRelease) + * 10. Update application status to ACTIVE */ export function applicationDeployEffect( applicationName: string, @@ -1065,21 +1127,12 @@ export function applicationDeployEffect( // If no extensions found, skip security scan and bundling (no-op) if (extensions.length === 0) { - yield* emitProgress(options, { - type: "step", - name: "application.activate", - status: "started", - }); - yield* callUpdateApp( - appResult.application.id, - { status: "ACTIVE" }, - { accessToken }, + yield* finalizeDeployActivation( + applicationId, + releaseId, + accessToken, + options, ); - yield* emitProgress(options, { - type: "step", - name: "application.activate", - status: "completed", - }); yield* emitProgress(options, { type: "step", name: "deploy", @@ -1374,22 +1427,12 @@ export function applicationDeployEffect( }); } - // Update application status to ACTIVE - yield* emitProgress(options, { - type: "step", - name: "application.activate", - status: "started", - }); - yield* callUpdateApp( - appResult.application.id, - { status: "ACTIVE" }, - { accessToken }, + yield* finalizeDeployActivation( + applicationId, + releaseId, + accessToken, + options, ); - yield* emitProgress(options, { - type: "step", - name: "application.activate", - status: "completed", - }); yield* emitProgress(options, { type: "step", name: "deploy", diff --git a/src/services/applications.ts b/src/services/applications.ts index 871a13b..6f1b9f7 100644 --- a/src/services/applications.ts +++ b/src/services/applications.ts @@ -120,6 +120,20 @@ export const CreateReleaseMutation = graphql(` } `); +export const ActivateReleaseMutation = graphql(` + mutation ActivateRelease($applicationId: ID!, $releaseId: ID!) { + activateRelease(applicationId: $applicationId, releaseId: $releaseId) { + id + version + description + status + activatedAt + createdAt + updatedAt + } + } +`); + export const EnableApplicationMutation = graphql(` mutation EnableApplication($input: MutationEnableStoreApplicationInput!) { enableStoreApplication(input: $input) { @@ -360,6 +374,35 @@ export function createReleaseEffect( }); } +export function activateReleaseEffect( + applicationId: string, + releaseId: string, + { accessToken }: { accessToken: string | null }, +) { + return Effect.gen(function* () { + if (!accessToken) { + return yield* Effect.fail( + new AuthenticationError({ + message: "Access token is required", + userMessage: "Authentication required", + }), + ); + } + + const client = yield* makeGraphQLClientEffect(); + + return yield* Effect.tryPromise({ + try: () => + client.request( + ActivateReleaseMutation, + { applicationId, releaseId }, + getRequestHeaders(accessToken), + ), + catch: mapGraphQLError, + }); + }); +} + export function enableApplicationEffect( input: { applicationName: string; storeId: string }, { accessToken }: { accessToken: string | null }, diff --git a/tests/setup/handlers/application-handlers.ts b/tests/setup/handlers/application-handlers.ts index c23040f..ec99c14 100644 --- a/tests/setup/handlers/application-handlers.ts +++ b/tests/setup/handlers/application-handlers.ts @@ -49,6 +49,9 @@ export const applicationHandlers = [ case "CreateRelease": return handleCreateReleaseMutation(variables); + case "ActivateRelease": + return handleActivateReleaseMutation(variables); + case "EnableApplication": return handleEnableApplicationMutation(variables); @@ -299,6 +302,56 @@ function handleCreateReleaseMutation(variables: Record) { }); } +function handleActivateReleaseMutation(variables: Record) { + const { applicationId, releaseId } = variables; + + if (!applicationId || !releaseId) { + return HttpResponse.json( + { + errors: [ + { + message: "applicationId and releaseId are required", + extensions: { code: "BAD_USER_INPUT" }, + }, + ], + }, + { status: 400 }, + ); + } + + const app = applicationFixtures.applications.find( + (app) => app.id === applicationId, + ); + if (!app) { + return HttpResponse.json( + { + errors: [ + { + message: `Application with id "${applicationId}" not found`, + extensions: { code: "NOT_FOUND" }, + }, + ], + }, + { status: 404 }, + ); + } + + const now = new Date().toISOString(); + return HttpResponse.json({ + data: { + activateRelease: { + id: releaseId, + version: "1.0.0", + description: "Activated release", + status: "ACTIVE", + activatedAt: now, + createdAt: now, + updatedAt: now, + }, + }, + }); +} + function handleEnableApplicationMutation(variables: Record) { const { input } = variables; diff --git a/tests/unit/application-deploy-security.test.ts b/tests/unit/application-deploy-security.test.ts index 671dc76..b22f9fa 100644 --- a/tests/unit/application-deploy-security.test.ts +++ b/tests/unit/application-deploy-security.test.ts @@ -85,6 +85,20 @@ describe("Application Deploy with Security Scanning", () => { }), ); + vi.spyOn(applicationsService, "activateReleaseEffect").mockReturnValue( + Effect.succeed({ + activateRelease: { + id: "release-123", + version: "1.0.0", + description: "Initial release", + status: "ACTIVE", + activatedAt: "2025-01-01T00:00:00Z", + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", + }, + }), + ); + // Setup spy for getExtensionsFromConfig (will be configured per test) getExtensionsFromConfigSpy = vi.spyOn( configService, @@ -153,6 +167,7 @@ exec('rm -rf /'); // SEC001 violation const exit = await runEffectExit(applicationDeployEffect("test-app")); const err = extractFailure(exit) as { userMessage: string }; expect(err.userMessage).toContain("blocked"); + expect(applicationsService.activateReleaseEffect).not.toHaveBeenCalled(); expect( applicationsService.updateApplicationEffect, ).not.toHaveBeenCalled(); @@ -242,6 +257,14 @@ export function greet(name: string) { event.status === "completed", ), ).toBe(true); + expect( + progressEvents.some( + (event) => + event.type === "step" && + event.name === "release.activate" && + event.status === "completed", + ), + ).toBe(true); expect( progressEvents.some( (event) => @@ -250,11 +273,23 @@ export function greet(name: string) { event.status === "completed", ), ).toBe(true); + expect(applicationsService.activateReleaseEffect).toHaveBeenCalledWith( + "app-123", + "release-123", + { accessToken: "test-token" }, + ); expect(applicationsService.updateApplicationEffect).toHaveBeenCalledWith( "app-123", { status: "ACTIVE" }, { accessToken: "test-token" }, ); + const activateOrder = + vi.mocked(applicationsService.activateReleaseEffect).mock + .invocationCallOrder[0]; + const updateOrder = + vi.mocked(applicationsService.updateApplicationEffect).mock + .invocationCallOrder[0]; + expect(activateOrder).toBeLessThan(updateOrder); } finally { process.chdir(originalCwd); } @@ -359,6 +394,7 @@ exec('dangerous command'); // SEC001 const exit = await runEffectExit(applicationDeployEffect("test-app")); const err = extractFailure(exit) as { userMessage: string }; expect(err.userMessage).toContain("blocked"); + expect(applicationsService.activateReleaseEffect).not.toHaveBeenCalled(); expect( applicationsService.updateApplicationEffect, ).not.toHaveBeenCalled(); @@ -380,11 +416,23 @@ exec('dangerous command'); // SEC001 expect(result.totalExtensions).toBe(0); expect(result.securityReports).toHaveLength(0); // Deployment should still proceed + expect(applicationsService.activateReleaseEffect).toHaveBeenCalledWith( + "app-123", + "release-123", + { accessToken: "test-token" }, + ); expect(applicationsService.updateApplicationEffect).toHaveBeenCalledWith( "app-123", { status: "ACTIVE" }, { accessToken: "test-token" }, ); + const activateOrder = + vi.mocked(applicationsService.activateReleaseEffect).mock + .invocationCallOrder[0]; + const updateOrder = + vi.mocked(applicationsService.updateApplicationEffect).mock + .invocationCallOrder[0]; + expect(activateOrder).toBeLessThan(updateOrder); } finally { process.chdir(originalCwd); } @@ -410,6 +458,7 @@ exec('dangerous command'); // SEC001 const exit = await runEffectExit(applicationDeployEffect("test-app")); const err = extractFailure(exit) as { userMessage: string }; expect(err.userMessage).toContain("handle path"); + expect(applicationsService.activateReleaseEffect).not.toHaveBeenCalled(); expect( applicationsService.updateApplicationEffect, ).not.toHaveBeenCalled(); @@ -438,6 +487,7 @@ exec('dangerous command'); // SEC001 const exit = await runEffectExit(applicationDeployEffect("test-app")); const err = extractFailure(exit) as { userMessage: string }; expect(err.userMessage).toContain("source path"); + expect(applicationsService.activateReleaseEffect).not.toHaveBeenCalled(); expect( applicationsService.updateApplicationEffect, ).not.toHaveBeenCalled(); @@ -520,6 +570,7 @@ export function run() { expect(err.userMessage).toBe("upload failed"); expect(existsSync(artifactPath)).toBe(false); expect(existsSync(sourcemapPath)).toBe(false); + expect(applicationsService.activateReleaseEffect).not.toHaveBeenCalled(); expect( applicationsService.updateApplicationEffect, ).not.toHaveBeenCalled();