From d81aed39f256571684254ed5ca37b6f7955df57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Tue, 30 Jun 2026 17:20:57 +0200 Subject: [PATCH 1/4] chore(release): add production promotion workflow (#2786) --- .github/workflows/promote-production.yml | 162 +++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 .github/workflows/promote-production.yml diff --git a/.github/workflows/promote-production.yml b/.github/workflows/promote-production.yml new file mode 100644 index 00000000000..e4520d8c222 --- /dev/null +++ b/.github/workflows/promote-production.yml @@ -0,0 +1,162 @@ +name: Open production promotion PR + +on: + workflow_dispatch: + inputs: + confirm: + description: 'Type "confirm" to open the production promotion PR' + required: true + auto_merge: + description: 'Enable auto-merge once branch protection requirements are met' + required: true + default: true + type: boolean + +permissions: + checks: read + contents: write + pull-requests: write + statuses: read + +jobs: + open-promotion-pr: + name: Open staging to main PR + runs-on: ubuntu-latest + steps: + - name: Validate confirmation + if: ${{ inputs.confirm != 'confirm' }} + run: | + echo 'Invalid confirmation. Type "confirm" to open the production promotion PR.' + exit 1 + + - name: Check if staging has changes to promote + id: compare + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + REPOSITORY: ${{ github.repository }} + run: | + ahead_by="$(gh api "repos/${REPOSITORY}/compare/main...staging" --jq '.ahead_by')" + echo "ahead_by=${ahead_by}" >> "$GITHUB_OUTPUT" + + if [ "$ahead_by" = "0" ]; then + echo "staging is already aligned with main. Nothing to promote." + fi + + - name: Find existing promotion PR + id: existing-pr + if: ${{ steps.compare.outputs.ahead_by != '0' }} + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + REPOSITORY: ${{ github.repository }} + run: | + pr_url="$(gh pr list \ + --repo "$REPOSITORY" \ + --base main \ + --head staging \ + --state open \ + --json url \ + --jq '.[0].url')" + + echo "url=${pr_url}" >> "$GITHUB_OUTPUT" + + - name: Create promotion PR + id: create-pr + if: ${{ steps.compare.outputs.ahead_by != '0' && steps.existing-pr.outputs.url == '' }} + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + REPOSITORY: ${{ github.repository }} + run: | + pr_body="$(printf '%s\n\n%s' \ + 'Opens the production promotion path from staging to main. Merging this PR triggers semantic-release, then the existing production deployment workflow.' \ + 'Do not squash this PR. Use a merge commit to preserve the original conventional commits for semantic-release.')" + + pr_url="$(gh pr create \ + --repo "$REPOSITORY" \ + --base main \ + --head staging \ + --title "chore(release): promote staging to production" \ + --body "$pr_body")" + + echo "url=${pr_url}" >> "$GITHUB_OUTPUT" + echo "Created promotion PR: ${pr_url}" + + - name: Wait for required CI checks + if: ${{ inputs.auto_merge && steps.compare.outputs.ahead_by != '0' }} + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + REPOSITORY: ${{ github.repository }} + EXISTING_PR_URL: ${{ steps.existing-pr.outputs.url }} + CREATED_PR_URL: ${{ steps.create-pr.outputs.url }} + run: | + set -euo pipefail + + pr_url="${EXISTING_PR_URL:-$CREATED_PR_URL}" + + for attempt in $(seq 1 60); do + set +e + checks="$(gh pr checks "$pr_url" \ + --repo "$REPOSITORY" \ + --json name,state,workflow \ + --jq '[.[] | select(((.name // "") | ascii_downcase | contains("codecov") | not) and ((.workflow // "") | ascii_downcase | contains("codecov") | not))]')" + checks_exit_code="$?" + set -e + + if [ "$checks_exit_code" != "0" ] && [ "$checks_exit_code" != "1" ] && [ "$checks_exit_code" != "8" ]; then + echo "Unable to read PR checks." + exit "$checks_exit_code" + fi + + total_count="$(echo "$checks" | jq 'length')" + blocking_count="$(echo "$checks" | jq '[.[] | select(.state == "FAILURE" or .state == "CANCELLED" or .state == "SKIPPED" or .state == "TIMED_OUT")] | length')" + pending_count="$(echo "$checks" | jq '[.[] | select(.state == "PENDING" or .state == "QUEUED" or .state == "IN_PROGRESS" or .state == "WAITING" or .state == "REQUESTED")] | length')" + + if [ "$blocking_count" != "0" ]; then + echo "A non-Codecov CI check failed. Production promotion is blocked." + echo "$checks" | jq -r '.[] | select(.state == "FAILURE" or .state == "CANCELLED" or .state == "SKIPPED" or .state == "TIMED_OUT") | "- \(.workflow): \(.name) [\(.state)]"' + exit 1 + fi + + if [ "$total_count" = "0" ]; then + echo "No non-Codecov CI checks found yet. Waiting before enabling auto-merge." + elif [ "$pending_count" = "0" ]; then + echo "All non-Codecov CI checks passed." + exit 0 + fi + + echo "Waiting for non-Codecov CI checks to finish. Attempt ${attempt}/60." + sleep 30 + done + + echo "Timed out waiting for non-Codecov CI checks." + exit 1 + + - name: Enable auto-merge + if: ${{ inputs.auto_merge && steps.compare.outputs.ahead_by != '0' }} + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + REPOSITORY: ${{ github.repository }} + EXISTING_PR_URL: ${{ steps.existing-pr.outputs.url }} + CREATED_PR_URL: ${{ steps.create-pr.outputs.url }} + run: | + pr_url="${EXISTING_PR_URL:-$CREATED_PR_URL}" + + gh pr merge "$pr_url" \ + --repo "$REPOSITORY" \ + --auto \ + --merge + + echo "Auto-merge enabled for ${pr_url}" + + - name: Summary + env: + AHEAD_BY: ${{ steps.compare.outputs.ahead_by }} + EXISTING_PR_URL: ${{ steps.existing-pr.outputs.url }} + CREATED_PR_URL: ${{ steps.create-pr.outputs.url }} + run: | + if [ "$AHEAD_BY" = "0" ]; then + echo "staging is already aligned with main. Nothing to promote." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + pr_url="${EXISTING_PR_URL:-$CREATED_PR_URL}" + echo "Promotion PR: ${pr_url}" >> "$GITHUB_STEP_SUMMARY" From 28a7d664f1f01b1294cfeea285dfc3b24c6946a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Tue, 30 Jun 2026 17:40:16 +0200 Subject: [PATCH 2/4] fix(release): use rebase for production promotion (#2789) --- .github/workflows/promote-production.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/promote-production.yml b/.github/workflows/promote-production.yml index e4520d8c222..5860ed67c93 100644 --- a/.github/workflows/promote-production.yml +++ b/.github/workflows/promote-production.yml @@ -68,7 +68,7 @@ jobs: run: | pr_body="$(printf '%s\n\n%s' \ 'Opens the production promotion path from staging to main. Merging this PR triggers semantic-release, then the existing production deployment workflow.' \ - 'Do not squash this PR. Use a merge commit to preserve the original conventional commits for semantic-release.')" + 'Do not squash this PR. Use rebase merge to preserve the original conventional commits for semantic-release.')" pr_url="$(gh pr create \ --repo "$REPOSITORY" \ @@ -143,7 +143,7 @@ jobs: gh pr merge "$pr_url" \ --repo "$REPOSITORY" \ --auto \ - --merge + --rebase echo "Auto-merge enabled for ${pr_url}" From 7bdaf1cd22fd8816ae8c729df1398a94a909f28a Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 1 Jul 2026 15:06:14 +0200 Subject: [PATCH 3/4] feat(blueprints): creation flow improvements (#2787) * Add icons for blueprints * Improve blueprint-based service creation flow loading behavior This is done by listening to a new WS API endpoint * Reuse ServiceAvatar component * Remove aria-hidden wrappers * Refactor WS subscription and submit logic * Rename onInvalidateOperation to onQueryInvalidated * Update success toast display strategy * Add timeout fallback whem WS is not received --- libs/domains/services/feature/src/index.ts | 1 + .../blueprint-details-panel.spec.tsx | 3 +- .../blueprint-details-panel.tsx | 14 +- ...-blueprint-service-created-socket.spec.tsx | 100 +++++++++ .../use-blueprint-service-created-socket.ts | 45 ++++ .../use-create-blueprint.ts | 3 - .../blueprint-creation-flow.spec.tsx | 207 +++++++++++++++--- .../blueprint-step-summary.tsx | 172 ++++++++++++--- .../blueprint-card/blueprint-card.tsx | 9 +- .../src/lib/service-new/service-new.spec.tsx | 3 +- .../use-react-query-ws-subscription.spec.tsx | 5 +- .../use-react-query-ws-subscription.ts | 26 ++- 12 files changed, 512 insertions(+), 76 deletions(-) create mode 100644 libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.spec.tsx create mode 100644 libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.ts diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index 09c53f2d1ff..438cb9322fe 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -33,6 +33,7 @@ export * from './lib/hooks/use-argocd-manifest/use-argocd-manifest' export * from './lib/hooks/use-blueprint-catalog/use-blueprint-catalog' export * from './lib/hooks/use-blueprint-catalog-service-readme/use-blueprint-catalog-service-readme' export * from './lib/hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest' +export * from './lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket' export * from './lib/blueprint-query-boundary/blueprint-query-boundary' export * from './lib/hooks/use-service-deployment-and-running-statuses/use-service-deployment-and-running-statuses' export * from './lib/hooks/use-delete-all-services/use-delete-all-services' diff --git a/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.spec.tsx b/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.spec.tsx index e3633bf3b58..f19c9f549b1 100644 --- a/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.spec.tsx +++ b/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.spec.tsx @@ -47,7 +47,7 @@ const blueprint: BlueprintItem = { name: 'AWS S3 Bucket', kind: 'ServiceBlueprint', description: 'Object storage with server-side encryption.', - icon: 'https://cdn.qovery.com/icons/s3.svg', + icon: 'app://qovery-console/s3', categories: ['storage'], provider: 'aws', serviceFamily: 's3', @@ -85,6 +85,7 @@ describe('BlueprintDetailsPanel', () => { const dialog = screen.getByRole('dialog', { name: 'AWS S3 Bucket' }) + expect(dialog.querySelector('img[src="/assets/devicon/s3.svg"]')).toBeInTheDocument() expect(within(dialog).getByText('Object storage with server-side encryption.')).toBeInTheDocument() expect(within(dialog).getByText('AWS')).toBeInTheDocument() expect(within(dialog).getByText('v1')).toBeInTheDocument() diff --git a/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.tsx b/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.tsx index 9458cc0ea74..387a062f3da 100644 --- a/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.tsx +++ b/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.tsx @@ -8,6 +8,7 @@ import { Badge, Button, ExternalLink, Icon, Link, Sheet } from '@qovery/shared/u import { twMerge } from '@qovery/shared/util-js' import { BlueprintQueryBoundary } from '../blueprint-query-boundary/blueprint-query-boundary' import { useBlueprintCatalogServiceReadme } from '../hooks/use-blueprint-catalog-service-readme/use-blueprint-catalog-service-readme' +import { ServiceAvatar } from '../service-avatar/service-avatar' function getBlueprintRepositoryName({ provider, serviceFamily }: BlueprintItem) { return `qovery-blueprints/${serviceFamily || provider}` @@ -169,8 +170,17 @@ function BlueprintDetailsPanelContent({
- - + + {blueprint.name} diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.spec.tsx b/libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.spec.tsx new file mode 100644 index 00000000000..ce0b0705d8b --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.spec.tsx @@ -0,0 +1,100 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook } from '@testing-library/react' +import { type PropsWithChildren } from 'react' +import { queries, useReactQueryWsSubscription } from '@qovery/state/util-queries' +import { useBlueprintServiceCreatedSocket } from './use-blueprint-service-created-socket' + +jest.mock('@qovery/shared/util-node-env', () => ({ + QOVERY_WS: 'wss://ws.qovery.com', +})) + +jest.mock('@qovery/state/util-queries', () => ({ + ...jest.requireActual('@qovery/state/util-queries'), + useReactQueryWsSubscription: jest.fn(), +})) + +const useReactQueryWsSubscriptionMock = jest.mocked(useReactQueryWsSubscription) + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return {children} + } +} + +function renderUseBlueprintServiceCreatedSocket( + props: Parameters[0], + queryClient = new QueryClient() +) { + renderHook(() => useBlueprintServiceCreatedSocket(props), { + wrapper: createWrapper(queryClient), + }) + + return queryClient +} + +describe('useBlueprintServiceCreatedSocket', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should subscribe to the blueprint service-created endpoint with route params', () => { + renderUseBlueprintServiceCreatedSocket({ + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + }) + + expect(useReactQueryWsSubscriptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'wss://ws.qovery.com/blueprint/service-created', + urlSearchParams: { + organization: 'org-1', + project: 'proj-1', + environment: 'env-1', + }, + enabled: true, + }) + ) + }) + + it('should not subscribe until all params are available', () => { + renderUseBlueprintServiceCreatedSocket({ + organizationId: 'org-1', + projectId: 'proj-1', + enabled: true, + }) + + expect(useReactQueryWsSubscriptionMock).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })) + }) + + it('should invalidate the environment service list when the service-created event is received', () => { + const queryClient = renderUseBlueprintServiceCreatedSocket({ + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + }) + const invalidateQueries = jest.spyOn(queryClient, 'invalidateQueries') + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + subscriptionConfig?.onMessage?.(queryClient, {}) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: queries.services.list('env-1').queryKey, + }) + }) + + it('should notify the caller when a query invalidation event is processed', () => { + const onServiceCreated = jest.fn() + const queryClient = renderUseBlueprintServiceCreatedSocket({ + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + onServiceCreated, + }) + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + subscriptionConfig?.onQueryInvalidated?.(queryClient, { entity: ['services'], id: 'service-1' }) + + expect(onServiceCreated).toHaveBeenCalledTimes(1) + }) +}) diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.ts b/libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.ts new file mode 100644 index 00000000000..88876b1199b --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.ts @@ -0,0 +1,45 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' +import { QOVERY_WS } from '@qovery/shared/util-node-env' +import { queries, useReactQueryWsSubscription } from '@qovery/state/util-queries' + +export interface UseBlueprintServiceCreatedSocketProps { + organizationId?: string + projectId?: string + environmentId?: string + enabled?: boolean + onServiceCreated?: () => void +} + +export function useBlueprintServiceCreatedSocket({ + organizationId, + projectId, + environmentId, + enabled = true, + onServiceCreated, +}: UseBlueprintServiceCreatedSocketProps) { + const queryClient = useQueryClient() + + const handleServiceCreated = useCallback(() => { + if (!environmentId) { + return + } + + queryClient.invalidateQueries({ + queryKey: queries.services.list(environmentId).queryKey, + }) + onServiceCreated?.() + }, [environmentId, onServiceCreated, queryClient]) + + useReactQueryWsSubscription({ + url: QOVERY_WS + '/blueprint/service-created', + urlSearchParams: { + organization: organizationId, + project: projectId, + environment: environmentId, + }, + enabled: enabled && Boolean(organizationId) && Boolean(projectId) && Boolean(environmentId), + onMessage: handleServiceCreated, + onQueryInvalidated: handleServiceCreated, + }) +} diff --git a/libs/domains/services/feature/src/lib/hooks/use-create-blueprint/use-create-blueprint.ts b/libs/domains/services/feature/src/lib/hooks/use-create-blueprint/use-create-blueprint.ts index ee964fc6fcf..eeb62611a24 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-create-blueprint/use-create-blueprint.ts +++ b/libs/domains/services/feature/src/lib/hooks/use-create-blueprint/use-create-blueprint.ts @@ -12,9 +12,6 @@ export function useCreateBlueprint() { }) }, meta: { - notifyOnSuccess: { - title: 'Your service has been created', - }, notifyOnError: true, }, }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx index 02dca4ea00b..f790615b34b 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx @@ -1,5 +1,5 @@ import { useParams } from '@tanstack/react-router' -import { within } from '@testing-library/react' +import { act, within } from '@testing-library/react' import type { BlueprintItem, BlueprintManifestResponseResultsInner } from 'qovery-typescript-axios' import { type ReactNode, useEffect, useState } from 'react' import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' @@ -14,7 +14,9 @@ const mockNavigate = jest.fn() const mockUseSearch = jest.fn() const mockUseBlueprintCatalogServiceManifest = jest.fn() const mockUseBlueprintCatalogServiceReadme = jest.fn() +const mockUseBlueprintServiceCreatedSocket = jest.fn() const mockCreateBlueprint = jest.fn() +const mockToast = jest.fn() jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), @@ -27,6 +29,7 @@ jest.mock('@qovery/shared/ui', () => { const actual = jest.requireActual('@qovery/shared/ui') return { ...actual, + toast: (...args: Parameters) => mockToast(...args), Link: ({ children, to, ...props }: { children: ReactNode; to?: string; [key: string]: unknown }) => typeof to === 'string' ? ( @@ -46,6 +49,10 @@ jest.mock('../../hooks/use-blueprint-catalog-service-readme/use-blueprint-catalo useBlueprintCatalogServiceReadme: (props: unknown) => mockUseBlueprintCatalogServiceReadme(props), })) +jest.mock('../../hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket', () => ({ + useBlueprintServiceCreatedSocket: (props: unknown) => mockUseBlueprintServiceCreatedSocket(props), +})) + jest.mock('../../hooks/use-create-blueprint/use-create-blueprint', () => ({ useCreateBlueprint: () => ({ mutateAsync: mockCreateBlueprint, @@ -364,35 +371,175 @@ describe('BlueprintCreationFlow', () => { await screen.findByText(/custom-postgres/) await userEvent.click(screen.getByTestId('button-create-deploy')) - expect(mockCreateBlueprint).toHaveBeenCalledWith({ - environmentId: 'env-1', - deploy: true, - payload: { - name: 'custom-postgres', - tag: 'aws/postgres/17/1.0.0', - icon: 'https://cdn.qovery.com/icons/postgresql.svg', - variables: expect.arrayContaining([ - { - name: 'db_name', - value: 'production', - is_secret: false, - }, - { - name: 'db_username', - value: 'postgres', - is_secret: false, - }, - { - name: 'db_password', - value: 'super-secret', - is_secret: true, - }, - { - name: 'skip_final_snapshot', - value: 'true', - is_secret: false, - }, - ]), + await waitFor(() => { + expect(mockCreateBlueprint).toHaveBeenCalledWith({ + environmentId: 'env-1', + deploy: true, + payload: { + name: 'custom-postgres', + tag: 'aws/postgres/17/1.0.0', + icon: 'https://cdn.qovery.com/icons/postgresql.svg', + variables: expect.arrayContaining([ + { + name: 'db_name', + value: 'production', + is_secret: false, + }, + { + name: 'db_username', + value: 'postgres', + is_secret: false, + }, + { + name: 'db_password', + value: 'super-secret', + is_secret: true, + }, + { + name: 'skip_final_snapshot', + value: 'true', + is_secret: false, + }, + ]), + }, + }) + }) + }) + + it('should start listening for the blueprint service-created event before creating the blueprint', async () => { + jest.useFakeTimers() + let resolveCreateBlueprint: (value: { environment_id: string }) => void = jest.fn() + mockCreateBlueprint.mockImplementationOnce( + () => + new Promise<{ environment_id: string }>((resolve) => { + resolveCreateBlueprint = resolve + }) + ) + + const { userEvent } = renderBlueprintFlow( + + + + ) + + await screen.findByText(/custom-postgres/) + await userEvent.click(screen.getByTestId('button-create')) + + await waitFor(() => { + expect(mockCreateBlueprint).toHaveBeenCalled() + }) + + const enabledSocketCall = mockUseBlueprintServiceCreatedSocket.mock.calls.find( + ([props]) => + (props as { enabled?: boolean; organizationId?: string; projectId?: string; environmentId?: string }) + .enabled === true + ) + const enabledSocketCallIndex = mockUseBlueprintServiceCreatedSocket.mock.calls.findIndex( + ([props]) => + (props as { enabled?: boolean; organizationId?: string; projectId?: string; environmentId?: string }) + .enabled === true + ) + expect(enabledSocketCall?.[0]).toEqual( + expect.objectContaining({ + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + enabled: true, + }) + ) + expect(mockUseBlueprintServiceCreatedSocket.mock.invocationCallOrder[enabledSocketCallIndex]).toBeLessThan( + mockCreateBlueprint.mock.invocationCallOrder[0] + ) + expect(mockNavigate).not.toHaveBeenCalledWith({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + params: { + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + }, + }) + + await act(async () => { + resolveCreateBlueprint({ environment_id: 'env-1' }) + }) + expect(mockToast).not.toHaveBeenCalled() + + act(() => { + jest.advanceTimersByTime(29_999) + }) + expect(mockToast).not.toHaveBeenCalled() + + const socketProps = mockUseBlueprintServiceCreatedSocket.mock.calls.at(-1)?.[0] as { + onServiceCreated: () => void + } + act(() => { + socketProps.onServiceCreated() + }) + + expect(mockToast).toHaveBeenCalledWith('success', 'Your service has been created') + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + params: { + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + }, + }) + + act(() => { + jest.advanceTimersByTime(1) + }) + expect(mockToast).toHaveBeenCalledTimes(1) + }) + + it('should complete the blueprint creation flow after a fallback timeout when the websocket event is missed', async () => { + jest.useFakeTimers() + let resolveCreateBlueprint: (value: { environment_id: string }) => void = jest.fn() + mockCreateBlueprint.mockImplementationOnce( + () => + new Promise<{ environment_id: string }>((resolve) => { + resolveCreateBlueprint = resolve + }) + ) + + const { userEvent } = renderBlueprintFlow( + + + + ) + + await screen.findByText(/custom-postgres/) + await userEvent.click(screen.getByTestId('button-create')) + + await waitFor(() => { + expect(mockCreateBlueprint).toHaveBeenCalled() + }) + + await act(async () => { + resolveCreateBlueprint({ environment_id: 'env-1' }) + }) + + expect(mockToast).not.toHaveBeenCalled() + expect(mockNavigate).not.toHaveBeenCalledWith({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + params: { + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + }, + }) + + act(() => { + jest.advanceTimersByTime(30_000) + }) + + expect(mockToast).toHaveBeenCalledWith('success', 'Your service has been created') + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + params: { + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', }, }) }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-step-summary/blueprint-step-summary.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-step-summary/blueprint-step-summary.tsx index 22963be869f..8a5328247d3 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-step-summary/blueprint-step-summary.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-step-summary/blueprint-step-summary.tsx @@ -1,8 +1,10 @@ import { useNavigate, useParams } from '@tanstack/react-router' import posthog from 'posthog-js' -import { useEffect, useState } from 'react' +import { type BlueprintCreateRequest } from 'qovery-typescript-axios' +import { useCallback, useEffect, useRef, useState } from 'react' import { type ServiceCreateSection } from '@qovery/shared/router' -import { Button, FunnelFlowBody, Heading, Icon, Section, SummaryValue } from '@qovery/shared/ui' +import { Button, FunnelFlowBody, Heading, Icon, Section, SummaryValue, toast } from '@qovery/shared/ui' +import { useBlueprintServiceCreatedSocket } from '../../../hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket' import { useCreateBlueprint } from '../../../hooks/use-create-blueprint/use-create-blueprint' import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' import { @@ -12,6 +14,8 @@ import { isFieldValid, } from '../blueprint-creation-utils/blueprint-creation-utils' +const BLUEPRINT_SERVICE_CREATED_FALLBACK_TIMEOUT_MS = 30_000 + export function BlueprintStepSummary() { const navigate = useNavigate() const { organizationId = '', projectId = '', environmentId = '' } = useParams({ strict: false }) @@ -26,6 +30,14 @@ export function BlueprintStepSummary() { setCurrentStep, } = useBlueprintCreateContext() const [submitMode, setSubmitMode] = useState<'create' | 'create-and-deploy' | null>(null) + const [isWaitingForServiceCreated, setIsWaitingForServiceCreated] = useState(false) + const [pendingBlueprintCreation, setPendingBlueprintCreation] = useState<{ + deploy: boolean + payload: BlueprintCreateRequest + } | null>(null) + const hasHandledServiceCreatedRef = useRef(false) + const hasStartedBlueprintCreationRef = useRef(false) + const serviceCreatedFallbackTimeoutRef = useRef | null>(null) const { mutateAsync: createBlueprint } = useCreateBlueprint() const { fields, serviceName } = form.watch() const variableFields = [...requiredBlueprintFields, ...optionalBlueprintFields] @@ -37,10 +49,64 @@ export function BlueprintStepSummary() { navigate({ to: creationFlowUrl, search: { section } }) } + const navigateToEnvironmentOverview = useCallback(() => { + navigate({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + params: { + organizationId, + projectId, + environmentId, + }, + }) + }, [environmentId, navigate, organizationId, projectId]) + + const clearServiceCreatedFallbackTimeout = useCallback(() => { + if (!serviceCreatedFallbackTimeoutRef.current) { + return + } + + clearTimeout(serviceCreatedFallbackTimeoutRef.current) + serviceCreatedFallbackTimeoutRef.current = null + }, []) + + const handleBlueprintServiceCreated = useCallback(() => { + if (hasHandledServiceCreatedRef.current) { + return + } + + hasHandledServiceCreatedRef.current = true + clearServiceCreatedFallbackTimeout() + setIsWaitingForServiceCreated(false) + setSubmitMode(null) + toast('success', 'Your service has been created') + navigateToEnvironmentOverview() + }, [clearServiceCreatedFallbackTimeout, navigateToEnvironmentOverview]) + + const startServiceCreatedFallbackTimeout = useCallback(() => { + if (hasHandledServiceCreatedRef.current) { + return + } + + clearServiceCreatedFallbackTimeout() + serviceCreatedFallbackTimeoutRef.current = setTimeout(() => { + handleBlueprintServiceCreated() + }, BLUEPRINT_SERVICE_CREATED_FALLBACK_TIMEOUT_MS) + }, [clearServiceCreatedFallbackTimeout, handleBlueprintServiceCreated]) + + useBlueprintServiceCreatedSocket({ + organizationId, + projectId, + environmentId, + enabled: isWaitingForServiceCreated, + onServiceCreated: handleBlueprintServiceCreated, + }) + useEffect(() => { setCurrentStep(2) }, [setCurrentStep]) + useEffect(() => () => clearServiceCreatedFallbackTimeout(), [clearServiceCreatedFallbackTimeout]) + useEffect(() => { if (!serviceName.trim() || !isBlueprintSetupValid) { navigate({ to: creationFlowUrl }) @@ -48,40 +114,80 @@ export function BlueprintStepSummary() { } }, [creationFlowUrl, isBlueprintSetupValid, navigate, serviceName]) - const handleSubmit = async (withDeploy: boolean) => { - setSubmitMode(withDeploy ? 'create-and-deploy' : 'create') - const formValues = form.getValues() + useEffect(() => { + if (!pendingBlueprintCreation || !isWaitingForServiceCreated || hasStartedBlueprintCreationRef.current) { + return + } - try { - await createBlueprint({ - environmentId, - deploy: withDeploy, - payload: { - name: formValues.serviceName, - tag: blueprint.majorVersions[0]?.latestTag ?? '', - icon: blueprint.icon, - variables: buildBlueprintVariables(formValues.fields, blueprintFields), - }, - }) - - posthog.capture('create-service', { - selectedServiceType: 'blueprint', - selectedServiceSubType: blueprint.serviceFamily ?? blueprint.provider, - }) - - navigate({ - to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', - params: { - organizationId, - projectId, + let shouldUpdateState = true + const blueprintCreation = pendingBlueprintCreation + hasStartedBlueprintCreationRef.current = true + + async function createPendingBlueprint() { + try { + await createBlueprint({ environmentId, - }, - }) - } catch { - // errors are surfaced by mutation notifications - } finally { - setSubmitMode(null) + deploy: blueprintCreation.deploy, + payload: blueprintCreation.payload, + }) + + if (!shouldUpdateState) { + return + } + + posthog.capture('create-service', { + selectedServiceType: 'blueprint', + selectedServiceSubType: blueprint.serviceFamily ?? blueprint.provider, + }) + setPendingBlueprintCreation(null) + startServiceCreatedFallbackTimeout() + } catch { + if (!shouldUpdateState) { + return + } + + // errors are surfaced by mutation notifications + clearServiceCreatedFallbackTimeout() + hasStartedBlueprintCreationRef.current = false + setPendingBlueprintCreation(null) + setIsWaitingForServiceCreated(false) + setSubmitMode(null) + } + } + + createPendingBlueprint() + + return () => { + shouldUpdateState = false } + }, [ + blueprint.provider, + blueprint.serviceFamily, + clearServiceCreatedFallbackTimeout, + createBlueprint, + environmentId, + isWaitingForServiceCreated, + pendingBlueprintCreation, + startServiceCreatedFallbackTimeout, + ]) + + const handleSubmit = (withDeploy: boolean) => { + const formValues = form.getValues() + + setSubmitMode(withDeploy ? 'create-and-deploy' : 'create') + clearServiceCreatedFallbackTimeout() + hasHandledServiceCreatedRef.current = false + hasStartedBlueprintCreationRef.current = false + setIsWaitingForServiceCreated(true) + setPendingBlueprintCreation({ + deploy: withDeploy, + payload: { + name: formValues.serviceName, + tag: blueprint.majorVersions[0]?.latestTag ?? '', + icon: blueprint.icon, + variables: buildBlueprintVariables(formValues.fields, blueprintFields), + }, + }) } return ( diff --git a/libs/domains/services/feature/src/lib/service-new/blueprint-card/blueprint-card.tsx b/libs/domains/services/feature/src/lib/service-new/blueprint-card/blueprint-card.tsx index 06e9514a35b..2667dd1d94e 100644 --- a/libs/domains/services/feature/src/lib/service-new/blueprint-card/blueprint-card.tsx +++ b/libs/domains/services/feature/src/lib/service-new/blueprint-card/blueprint-card.tsx @@ -1,6 +1,7 @@ import { useParams } from '@tanstack/react-router' import { type BlueprintItem } from 'qovery-typescript-axios' import { Button, Link } from '@qovery/shared/ui' +import { ServiceAvatar } from '../../service-avatar/service-avatar' export function BlueprintCard({ blueprint, @@ -14,7 +15,13 @@ export function BlueprintCard({ return (
- +

{blueprint.name}

{blueprint.description}

diff --git a/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx b/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx index 6d8fd42d2c3..dc625b2e853 100644 --- a/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx @@ -76,7 +76,7 @@ const blueprints: BlueprintItem[] = [ name: 'AWS S3 Bucket', kind: 'ServiceBlueprint', description: 'Object storage with server-side encryption, versioning, and configurable lifecycle policies.', - icon: 'https://cdn.qovery.com/icons/s3.svg', + icon: 'app://qovery-console/s3', categories: ['storage'], provider: 'aws', serviceFamily: 's3', @@ -158,6 +158,7 @@ describe('ServiceNew', () => { const blueprintsSectionScreen = within(blueprintsSection as HTMLElement) expect(blueprintsSectionScreen.getByText('AWS S3 Bucket')).toBeInTheDocument() expect(blueprintsSectionScreen.getByText('Redis')).toBeInTheDocument() + expect(blueprintsSection?.querySelector('img[src="/assets/devicon/s3.svg"]')).toBeInTheDocument() const deployLinks = blueprintsSectionScreen.getAllByRole('link', { name: 'Deploy' }) expect(deployLinks).toHaveLength(2) expect(deployLinks[0]).toHaveAttribute( diff --git a/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.spec.tsx b/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.spec.tsx index 3aba7d6dada..50e220f3020 100644 --- a/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.spec.tsx +++ b/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.spec.tsx @@ -24,6 +24,7 @@ describe('useReactQueryWsSubscription', () => { let server: WS beforeEach(() => { + jest.clearAllMocks() server = new WS('ws://localhost:1234', { jsonProtocol: true }) }) @@ -71,8 +72,9 @@ describe('useReactQueryWsSubscription', () => { }) it('should invalidate query keys on invalidate operation message', async () => { + const onQueryInvalidated = jest.fn() const { unmount } = renderHook(() => - useReactQueryWsSubscription({ url: 'ws://localhost:1234', onMessage: jest.fn() }) + useReactQueryWsSubscription({ url: 'ws://localhost:1234', onMessage: jest.fn(), onQueryInvalidated }) ) const queryClient = useQueryClient() const connection = await server.connected @@ -80,6 +82,7 @@ describe('useReactQueryWsSubscription', () => { server.send({ entity: ['projects', 'list'] }) expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(1, { queryKey: ['projects', 'list'] }) + expect(onQueryInvalidated).toHaveBeenCalledWith(queryClient, { entity: ['projects', 'list'] }) connection.close() unmount() }) diff --git a/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.ts b/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.ts index e4543794365..9c7d1b96f99 100644 --- a/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.ts +++ b/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.ts @@ -13,6 +13,7 @@ export interface UseReactQueryWsSubscriptionProps { /** WebSocket onmessage will be automatically handled if they are aligned with the expected format (https://tkdodo.eu/blog/using-web-sockets-with-react-query#consuming-data) otherwise you should provide an handler */ // eslint-disable-next-line @typescript-eslint/no-explicit-any onMessage?: (queryClient: QueryClient, data: any) => void + onQueryInvalidated?: (queryClient: QueryClient, data: InvalidateOperation) => void onOpen?: (queryClient: QueryClient, event: Event) => void onError?: (queryClient: QueryClient, event: Event) => void onClose?: (QueryClient: QueryClient, event: CloseEvent) => void @@ -36,6 +37,7 @@ export function useReactQueryWsSubscription({ url, urlSearchParams, onMessage, + onQueryInvalidated, onOpen, onError, onClose, @@ -62,6 +64,7 @@ export function useReactQueryWsSubscription({ } const searchParams = new URLSearchParams(_urlSearchParams) + const searchParamsString = searchParams.toString() const reconnectCount = useRef(0) useEffect(() => { @@ -70,6 +73,7 @@ export function useReactQueryWsSubscription({ } let timeout: ReturnType | undefined const controller = new AbortController() + let canReconnect = shouldReconnect async function connect({ signal }: { signal: AbortSignal }) { const token = await getAccessTokenSilently() @@ -77,7 +81,7 @@ export function useReactQueryWsSubscription({ // signal already aborted do nothing return } - const websocket = new WebSocket(`${url}?${searchParams.toString()}`, ['v1', 'auth.bearer.' + token]) + const websocket = new WebSocket(`${url}?${searchParamsString}`, ['v1', 'auth.bearer.' + token]) websocket.onopen = async (event) => { onOpen?.(queryClient, event) @@ -88,6 +92,7 @@ export function useReactQueryWsSubscription({ if (isInvalidateOperation(data)) { const queryKey = [...data.entity, data.id].filter(Boolean) queryClient.invalidateQueries({ queryKey }) + onQueryInvalidated?.(queryClient, data) } else { // XXX: Don't know how to handle it, let the caller handle it onMessage?.(queryClient, data) @@ -97,7 +102,7 @@ export function useReactQueryWsSubscription({ onError?.(queryClient, event) } websocket.onclose = async (event) => { - if (shouldReconnect && reconnectCount.current < MAX_RECONNECT_ATTEMPTS) { + if (canReconnect && reconnectCount.current < MAX_RECONNECT_ATTEMPTS) { timeout = setTimeout( function () { reconnectCount.current++ @@ -113,7 +118,7 @@ export function useReactQueryWsSubscription({ } const onAbort = () => { - shouldReconnect = false + canReconnect = false websocket.close() if (timeout) { clearTimeout(timeout) @@ -128,7 +133,20 @@ export function useReactQueryWsSubscription({ return () => { controller.abort() } - }, [queryClient, getAccessTokenSilently, onOpen, onMessage, onClose, url, searchParams.toString(), enabled]) + }, [ + queryClient, + getAccessTokenSilently, + onOpen, + onMessage, + onQueryInvalidated, + onError, + onClose, + shouldReconnect, + MAX_RECONNECT_ATTEMPTS, + url, + searchParamsString, + enabled, + ]) } export default useReactQueryWsSubscription From d1a2a37cdf9fd0c6ee020222e64178e64d39622d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Wed, 1 Jul 2026 15:32:40 +0200 Subject: [PATCH 4/4] fix(release): production promotion rebase automerge (#2790) * fix(release): use rebase for production promotion * fix(release): allow skipped promotion checks * fix(release): require staging smoke check --- .github/workflows/promote-production.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/promote-production.yml b/.github/workflows/promote-production.yml index 5860ed67c93..c15ad26b30b 100644 --- a/.github/workflows/promote-production.yml +++ b/.github/workflows/promote-production.yml @@ -96,8 +96,8 @@ jobs: set +e checks="$(gh pr checks "$pr_url" \ --repo "$REPOSITORY" \ - --json name,state,workflow \ - --jq '[.[] | select(((.name // "") | ascii_downcase | contains("codecov") | not) and ((.workflow // "") | ascii_downcase | contains("codecov") | not))]')" + --json event,name,state,workflow \ + --jq '[.[] | select((.event == "pull_request" or (.event == "push" and ((.name // "") | ascii_downcase | contains("staging smoke test")))) and ((.name // "") | ascii_downcase | contains("codecov") | not) and ((.workflow // "") | ascii_downcase | contains("codecov") | not))]')" checks_exit_code="$?" set -e @@ -107,12 +107,12 @@ jobs: fi total_count="$(echo "$checks" | jq 'length')" - blocking_count="$(echo "$checks" | jq '[.[] | select(.state == "FAILURE" or .state == "CANCELLED" or .state == "SKIPPED" or .state == "TIMED_OUT")] | length')" + blocking_count="$(echo "$checks" | jq '[.[] | select(.state == "FAILURE" or .state == "CANCELLED" or .state == "TIMED_OUT")] | length')" pending_count="$(echo "$checks" | jq '[.[] | select(.state == "PENDING" or .state == "QUEUED" or .state == "IN_PROGRESS" or .state == "WAITING" or .state == "REQUESTED")] | length')" if [ "$blocking_count" != "0" ]; then - echo "A non-Codecov CI check failed. Production promotion is blocked." - echo "$checks" | jq -r '.[] | select(.state == "FAILURE" or .state == "CANCELLED" or .state == "SKIPPED" or .state == "TIMED_OUT") | "- \(.workflow): \(.name) [\(.state)]"' + echo "A non-Codecov CI check failed, was cancelled, or timed out. Production promotion is blocked." + echo "$checks" | jq -r '.[] | select(.state == "FAILURE" or .state == "CANCELLED" or .state == "TIMED_OUT") | "- \(.workflow): \(.name) [\(.state)]"' exit 1 fi