From a53e1476d430a855388ef274e2d23eb61c0ad40d Mon Sep 17 00:00:00 2001 From: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com> Date: Wed, 19 Nov 2025 14:54:24 +0200 Subject: [PATCH 01/31] chore: Add nodes test coverage upload to workflow (no-changelog) (#21836) --- .github/workflows/units-tests-reusable.yml | 40 ++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/.github/workflows/units-tests-reusable.yml b/.github/workflows/units-tests-reusable.yml index ecc5d51a3f3..2c474831701 100644 --- a/.github/workflows/units-tests-reusable.yml +++ b/.github/workflows/units-tests-reusable.yml @@ -52,6 +52,7 @@ jobs: name: backend-unit - name: Upload coverage to Codecov + if: env.COVERAGE_ENABLED == 'true' uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -84,12 +85,46 @@ jobs: name: backend-integration - name: Upload coverage to Codecov + if: env.COVERAGE_ENABLED == 'true' uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} flags: backend-integration name: backend-integration + unit-test-nodes: + name: Nodes Unit Tests + runs-on: blacksmith-4vcpu-ubuntu-2204 + env: + COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.ref }} + + - name: Build + uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1 + with: + node-version: ${{ inputs.nodeVersion }} + + - name: Test Nodes + run: pnpm --filter=n8n-nodes-base test + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + name: nodes-unit + + - name: Upload coverage to Codecov + if: env.COVERAGE_ENABLED == 'true' + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: nodes-unit + name: nodes-unit + unit-test-frontend: name: Frontend (${{ matrix.shard }}/2) runs-on: blacksmith-4vcpu-ubuntu-2204 @@ -122,6 +157,7 @@ jobs: name: frontend-shard-${{ matrix.shard }} - name: Upload coverage to Codecov + if: env.COVERAGE_ENABLED == 'true' uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -131,9 +167,9 @@ jobs: unit-test: name: Unit tests runs-on: ubuntu-latest - needs: [unit-test-backend, integration-test-backend, unit-test-frontend] + needs: [unit-test-backend, integration-test-backend, unit-test-nodes, unit-test-frontend] if: always() steps: - name: Fail if tests failed - if: needs.unit-test-backend.result == 'failure' || needs.integration-test-backend.result == 'failure' || needs.unit-test-frontend.result == 'failure' + if: needs.unit-test-backend.result == 'failure' || needs.integration-test-backend.result == 'failure' || needs.unit-test-nodes.result == 'failure' || needs.unit-test-frontend.result == 'failure' run: exit 1 From da2446ead3239cbbb95c5552637aee157831e8c6 Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Wed, 19 Nov 2025 14:58:12 +0000 Subject: [PATCH 02/31] fix(ai-builder): Improving workflow builder following model instructions and using AI agent node (#22011) --- .../evaluations/chains/test-case-generator.ts | 12 ++++++------ .../src/tools/prompts/main-agent.prompt.ts | 10 +++++++++- .../src/app/constants/workflowSuggestions.ts | 12 ++++++------ 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/test-case-generator.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/test-case-generator.ts index 2dc777dd503..1dff531f14b 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/test-case-generator.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/test-case-generator.ts @@ -98,37 +98,37 @@ export const basicTestCases: TestCase[] = [ id: 'multi-agent-research', name: 'Multi-agent research workflow', prompt: - 'Create a multi-agent AI workflow using GPT-4.1-mini where several agents work together to research a topic, fact-check the findings, and write a report that\'s sent as an HTML email. One agent should gather recent, credible information about the topic. Another agent should verify the facts and only mark something as "verified" if it appears in at least two independent sources. A third agent should combine the verified information into a clear, well-written report under 1,000 words. A final agent should edit and format the report to make it look clean and professional in the body of the email. Use Gmail to send the report.', + 'Create a multi-agent AI workflow using `gpt-4.1-mini` where several agents work together to research a topic, fact-check the findings, and write a report that\'s sent as an HTML email. One agent should gather recent, credible information about the topic. Another agent should verify the facts and only mark something as "verified" if it appears in at least two independent sources. A third agent should combine the verified information into a clear, well-written report under 1,000 words. A final agent should edit and format the report to make it look clean and professional in the body of the email. Use Gmail to send the report.', }, { id: 'email-summary', name: 'Summarize emails with AI', prompt: - 'Create an automation that runs on Monday mornings. It reads my Gmail inbox from the weekend, analyzes them with GPT-4.1-mini to find action items and priorities, and emails me a structured email using Gmail.', + 'Create an automation that runs on Monday mornings. It reads my Gmail inbox from the weekend, analyzes them with `gpt-4.1-mini` to find action items and priorities, and emails me a structured email using Gmail.', }, { id: 'ai-news-digest', name: 'Daily AI news digest', prompt: - 'Build an automation that runs every night 8pm. Use the NewsAPI "/everything" endpoint to search for AI-related news from the day. Pick the top 5 articles and use OpenAI GPT-4.1-mini to summarize each in two sentences. Generate an image using OpenAI based on the top article\'s summary. Send a structured Telegram message.', + 'Build an automation that runs every night 8pm. Use the NewsAPI "/everything" endpoint to search for AI-related news from the day. Pick the top 5 articles and use OpenAI `gpt-4.1-mini` to summarize each in two sentences. Generate an image using OpenAI based on the top article\'s summary. Send a structured Telegram message.', }, { id: 'daily-weather-report', name: 'Daily weather report', prompt: - 'Create an automation that checks the weather for my location every morning at 5 a.m using OpenWeather. Send me a short weather report by email using Gmail. Use OpenAI GPT-4.1-mini to write a short, fun formatted email body by adding personality when describing the weather and how the day might feel. Include all details relevant to decide on my plans and clothes for the day.', + 'Create an automation that checks the weather for my location every morning at 5 a.m using OpenWeather. Send me a short weather report by email using Gmail. Use OpenAI `gpt-4.1-mini` to write a short, fun formatted email body by adding personality when describing the weather and how the day might feel. Include all details relevant to decide on my plans and clothes for the day.', }, { id: 'invoice-pipeline', name: 'Invoice processing pipeline', prompt: - 'Create an invoice processing workflow using an n8n Form. When a user submits an invoice file (PDF or image) with their email address, use OpenAI GPT-4.1-mini to extract invoice data. Then, validate the date format is correct, the currency is valid, and the total amount is greater than zero. If validation fails, email the user a clear error message that explains which check failed from my Gmail. If the data passes validation, store the structured result in a datatable plus email the user. Every Monday morning, generate a weekly spending report using GPT-4.1-mini based on stored invoices and send a clean email using Gmail.', + 'Create an invoice processing workflow using an n8n Form. When a user submits an invoice file (PDF or image) with their email address, use OpenAI `gpt-4.1-mini` to extract invoice data. Then, validate the date format is correct, the currency is valid, and the total amount is greater than zero. If validation fails, email the user a clear error message that explains which check failed from my Gmail. If the data passes validation, store the structured result in a datatable plus email the user. Every Monday morning, generate a weekly spending report using `gpt-4.1-mini` based on stored invoices and send a clean email using Gmail.', }, { id: 'rag-assistant', name: 'RAG knowledge assistant', prompt: - 'Build an automation that creates a document-to-chat RAG pipeline. The workflow starts with an n8n Form where a user uploads one or more files (PDF, CSV, or JSON). Each upload should trigger a process that reads the file, splits it into chunks, and generates embeddings using OpenAI GPT-4.1-mini model, saved in one Pinecone table. Add a second part of the workflow for querying: use a Chat Message Trigger to act as a chatbot interface. When a user sends a question, retrieve the top 5 most relevant chunks from Pinecone, pass them into GPT-4.1-mini as context, and have it answer naturally using only the retrieved information. If a question can\'t be answered confidently, the bot should respond with: "I couldn\'t find that in the uploaded documents." Log each chat interaction in a Data Table with the user query, matched file(s), and timestamp. Send a daily summary email through Gmail showing total questions asked, top files referenced, and any failed lookups.', + 'Build an automation that creates a document-to-chat RAG pipeline. The workflow starts with an n8n Form where a user uploads one or more files (PDF, CSV, or JSON). Each upload should trigger a process that reads the file, splits it into chunks, and generates embeddings using OpenAI `gpt-4.1-mini` model, saved in one Pinecone table. Add a second part of the workflow for querying: use a Chat Message Trigger to act as a chatbot interface. When a user sends a question, retrieve the top 5 most relevant chunks from Pinecone, pass them into `gpt-4.1-mini` as context, and have it answer naturally using only the retrieved information. If a question can\'t be answered confidently, the bot should respond with: "I couldn\'t find that in the uploaded documents." Log each chat interaction in a Data Table with the user query, matched file(s), and timestamp. Send a daily summary email through Gmail showing total questions asked, top files referenced, and any failed lookups.', }, { id: 'lead-qualification', diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts index 2e71a479064..e69dc538dee 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts @@ -37,7 +37,7 @@ Follow this proven sequence for creating robust workflows: - Why: Best practices help to inform which nodes to search for and use to build the workflow plus mistakes to avoid 2. **Discovery Phase** (parallel execution) - - Search for all required node types simultaneously + - Search for all required node types simultaneously, review the section for tips and best practices - Why: Ensures you work with actual available nodes, not assumptions 3. **Analysis Phase** (parallel execution) @@ -66,6 +66,14 @@ Follow this proven sequence for creating robust workflows: - Review and resolve any violations before finalizing - Why: Ensures structural issues are surfaced early; rerun validation after major updates + +When building AI workflows prefer the AI agent node to other text LLM nodes, unless the user specifies them by name. Summarization, analysis, information +extraction and classification can all be carried out by an AI agent node, correct system prompt, and structured output parser. +For the purposes of this section provider specific nodes can be described as nodes like @n8n/n8n-nodes-langchain.openAi. +Do not use provider specific nodes for text operations - instead use an AI agent node. +For generation/analysis of content other than text (images, video, audio) provider specific nodes should be used. + + Enforcing best practice compliance is MANDATORY diff --git a/packages/frontend/editor-ui/src/app/constants/workflowSuggestions.ts b/packages/frontend/editor-ui/src/app/constants/workflowSuggestions.ts index e805c5a129d..8aaec60d8b2 100644 --- a/packages/frontend/editor-ui/src/app/constants/workflowSuggestions.ts +++ b/packages/frontend/editor-ui/src/app/constants/workflowSuggestions.ts @@ -9,37 +9,37 @@ export const WORKFLOW_SUGGESTIONS: WorkflowSuggestion[] = [ id: 'multi-agent-research', summary: 'Multi-agent research workflow', prompt: - 'Create a multi-agent AI workflow using GPT-4.1-mini where several agents work together to research a topic, fact-check the findings, and write a report that\'s sent as an HTML email. One agent should gather recent, credible information about the topic. Another agent should verify the facts and only mark something as "verified" if it appears in at least two independent sources. A third agent should combine the verified information into a clear, well-written report under 1,000 words. A final agent should edit and format the report to make it look clean and professional in the body of the email. Use Gmail to send the report.', + 'Create a multi-agent AI workflow using `gpt-4.1-mini` where several agents work together to research a topic, fact-check the findings, and write a report that\'s sent as an HTML email. One agent should gather recent, credible information about the topic. Another agent should verify the facts and only mark something as "verified" if it appears in at least two independent sources. A third agent should combine the verified information into a clear, well-written report under 1,000 words. A final agent should edit and format the report to make it look clean and professional in the body of the email. Use Gmail to send the report.', }, { id: 'email-summary', summary: 'Summarize emails with AI', prompt: - 'Create an automation that runs on Monday mornings. It reads my Gmail inbox from the weekend, analyzes them with GPT-4.1-mini to find action items and priorities, and emails me a structured email using Gmail.', + 'Create an automation that runs on Monday mornings. It reads my Gmail inbox from the weekend, analyzes them with `gpt-4.1-mini` to find action items and priorities, and emails me a structured email using Gmail.', }, { id: 'ai-news-digest', summary: 'Daily AI news digest', prompt: - 'Build an automation that runs every night 8pm. Use the NewsAPI "/everything" endpoint to search for AI-related news from the day. Pick the top 5 articles and use OpenAI GPT-4.1-mini to summarize each in two sentences. Generate an image using OpenAI based on the top article\'s summary. Send a structured Telegram message.', + 'Build an automation that runs every night 8pm. Use the NewsAPI "/everything" endpoint to search for AI-related news from the day. Pick the top 5 articles and use OpenAI `gpt-4.1-mini` to summarize each in two sentences. Generate an image using OpenAI based on the top article\'s summary. Send a structured Telegram message.', }, { id: 'daily-weather-report', summary: 'Daily weather report', prompt: - 'Create an automation that checks the weather for my location every morning at 5 a.m using OpenWeather. Send me a short weather report by email using Gmail. Use OpenAI GPT-4.1-mini to write a short, fun formatted email body by adding personality when describing the weather and how the day might feel. Include all details relevant to decide on my plans and clothes for the day.', + 'Create an automation that checks the weather for my location every morning at 5 a.m using OpenWeather. Send me a short weather report by email using Gmail. Use OpenAI `gpt-4.1-mini` to write a short, fun formatted email body by adding personality when describing the weather and how the day might feel. Include all details relevant to decide on my plans and clothes for the day.', }, { id: 'invoice-pipeline', summary: 'Invoice processing pipeline', prompt: - 'Create an invoice processing workflow using an n8n Form. When a user submits an invoice file (PDF or image) with their email address, use OpenAI GPT-4.1-mini to extract invoice data. Then, validate the date format is correct, the currency is valid, and the total amount is greater than zero. If validation fails, email the user a clear error message that explains which check failed from my Gmail. If the data passes validation, store the structured result in a datatable plus email the user. Every Monday morning, generate a weekly spending report using GPT-4.1-mini based on stored invoices and send a clean email using Gmail.', + 'Create an invoice processing workflow using an n8n Form. When a user submits an invoice file (PDF or image) with their email address, use OpenAI `gpt-4.1-mini` to extract invoice data. Then, validate the date format is correct, the currency is valid, and the total amount is greater than zero. If validation fails, email the user a clear error message that explains which check failed from my Gmail. If the data passes validation, store the structured result in a datatable plus email the user. Every Monday morning, generate a weekly spending report using `gpt-4.1-mini` based on stored invoices and send a clean email using Gmail.', }, { id: 'rag-assistant', summary: 'RAG knowledge assistant', prompt: - 'Build an automation that creates a document-to-chat RAG pipeline. The workflow starts with an n8n Form where a user uploads one or more files (PDF, CSV, or JSON). Each upload should trigger a process that reads the file, splits it into chunks, and generates embeddings using OpenAI GPT-4.1-mini model, saved in one Pinecone table. Add a second part of the workflow for querying: use a Chat Message Trigger to act as a chatbot interface. When a user sends a question, retrieve the top 5 most relevant chunks from Pinecone, pass them into GPT-4.1-mini as context, and have it answer naturally using only the retrieved information. If a question can\'t be answered confidently, the bot should respond with: "I couldn\'t find that in the uploaded documents." Log each chat interaction in a Data Table with the user query, matched file(s), and timestamp. Send a daily summary email through Gmail showing total questions asked, top files referenced, and any failed lookups.', + 'Build an automation that creates a document-to-chat RAG pipeline. The workflow starts with an n8n Form where a user uploads one or more files (PDF, CSV, or JSON). Each upload should trigger a process that reads the file, splits it into chunks, and generates embeddings using OpenAI `gpt-4.1-mini` model, saved in one Pinecone table. Add a second part of the workflow for querying: use a Chat Message Trigger to act as a chatbot interface. When a user sends a question, retrieve the top 5 most relevant chunks from Pinecone, pass them into `gpt-4.1-mini` as context, and have it answer naturally using only the retrieved information. If a question can\'t be answered confidently, the bot should respond with: "I couldn\'t find that in the uploaded documents." Log each chat interaction in a Data Table with the user query, matched file(s), and timestamp. Send a daily summary email through Gmail showing total questions asked, top files referenced, and any failed lookups.', }, { id: 'lead-qualification', From e1fef800d2fdf59e5264076cbe525fa6f75a0e37 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Wed, 19 Nov 2025 17:12:20 +0200 Subject: [PATCH 03/31] feat(core): Add AWS Bedrock support to chat hub (no-changelog) (#22033) --- packages/@n8n/api-types/src/chat-hub.ts | 13 +- .../chat-hub/chat-hub-workflow.service.ts | 11 +- .../modules/chat-hub/chat-hub.constants.ts | 4 + .../src/modules/chat-hub/chat-hub.service.ts | 111 +++++++++++++++++- .../src/modules/chat-hub/context-limits.ts | 1 + .../src/features/ai/chatHub/constants.ts | 1 + 6 files changed, 137 insertions(+), 4 deletions(-) diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index 09998c0aa53..85eae52f065 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -17,6 +17,7 @@ export const chatHubLLMProviderSchema = z.enum([ 'google', 'azureOpenAi', 'ollama', + 'awsBedrock', ]); export type ChatHubLLMProvider = z.infer; @@ -40,6 +41,7 @@ export const PROVIDER_CREDENTIAL_TYPE_MAP: Record< google: 'googlePalmApi', ollama: 'ollamaApi', azureOpenAi: 'azureOpenAiApi', + awsBedrock: 'aws', }; export type ChatHubAgentTool = typeof JINA_AI_TOOL_NODE_TYPE | typeof SEAR_XNG_TOOL_NODE_TYPE; @@ -72,6 +74,11 @@ const ollamaModelSchema = z.object({ model: z.string(), }); +const awsBedrockModelSchema = z.object({ + provider: z.literal('awsBedrock'), + model: z.string(), +}); + const n8nModelSchema = z.object({ provider: z.literal('n8n'), workflowId: z.string(), @@ -88,6 +95,7 @@ export const chatHubConversationModelSchema = z.discriminatedUnion('provider', [ googleModelSchema, azureOpenAIModelSchema, ollamaModelSchema, + awsBedrockModelSchema, n8nModelSchema, chatAgentSchema, ]); @@ -97,12 +105,14 @@ export type ChatHubAnthropicModel = z.infer; export type ChatHubGoogleModel = z.infer; export type ChatHubAzureOpenAIModel = z.infer; export type ChatHubOllamaModel = z.infer; +export type ChatHubAwsBedrockModel = z.infer; export type ChatHubBaseLLMModel = | ChatHubOpenAIModel | ChatHubAnthropicModel | ChatHubGoogleModel | ChatHubAzureOpenAIModel - | ChatHubOllamaModel; + | ChatHubOllamaModel + | ChatHubAwsBedrockModel; export type ChatHubN8nModel = z.infer; export type ChatHubCustomAgentModel = z.infer; @@ -143,6 +153,7 @@ export const emptyChatModelsResponse: ChatModelsResponse = { google: { models: [] }, azureOpenAi: { models: [] }, ollama: { models: [] }, + awsBedrock: { models: [] }, n8n: { models: [] }, // eslint-disable-next-line @typescript-eslint/naming-convention 'custom-agent': { models: [] }, diff --git a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts index 41a65870f64..feffc3b60a8 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts @@ -452,7 +452,7 @@ export class ChatHubWorkflowService { return { ...common, parameters: { - model: { __rl: true, mode: 'id', value: model }, + model, options: {}, }, }; @@ -465,6 +465,15 @@ export class ChatHubWorkflowService { }, }; } + case 'awsBedrock': { + return { + ...common, + parameters: { + model, + options: {}, + }, + }; + } default: throw new OperationalError('Unsupported model provider'); } diff --git a/packages/cli/src/modules/chat-hub/chat-hub.constants.ts b/packages/cli/src/modules/chat-hub/chat-hub.constants.ts index 51a362d4847..33d02af6611 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.constants.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.constants.ts @@ -32,6 +32,10 @@ export const PROVIDER_NODE_TYPE_MAP: Record ({ - name: String(result.value), + name: result.name, description: result.description ?? null, model: { provider: 'google', @@ -331,7 +333,7 @@ export class ChatHubService { return { models: results.map((result) => ({ - name: String(result.value), + name: result.name, description: result.description ?? null, model: { provider: 'ollama', @@ -355,6 +357,111 @@ export class ChatHubService { }; } + private async fetchAwsBedrockModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + // From AWS Bedrock node + // https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts#L100 + // https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts#L155 + const foundationModelsRequest = this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/foundation-models?&byOutputModality=TEXT&byInferenceType=ON_DEMAND', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'modelSummaries', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.modelName}}', + description: '={{$responseItem.modelArn}}', + value: '={{$responseItem.modelId}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.awsBedrock, + {}, + credentials, + ); + + const inferenceProfileModelsRequest = this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/inference-profiles?maxResults=1000', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'inferenceProfileSummaries', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.inferenceProfileName}}', + description: + '={{$responseItem.description || $responseItem.inferenceProfileArn}}', + value: '={{$responseItem.inferenceProfileId}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.awsBedrock, + {}, + credentials, + ); + + const [foundationModels, inferenceProfileModels] = await Promise.all([ + foundationModelsRequest, + inferenceProfileModelsRequest, + ]); + + return { + models: foundationModels.concat(inferenceProfileModels).map((result) => ({ + name: result.name, + description: result.description ?? String(result.value), + model: { + provider: 'awsBedrock', + model: String(result.value), + }, + createdAt: null, + updatedAt: null, + })), + }; + } + private async fetchAgentWorkflowsAsModels(user: User): Promise { const nodeTypes = [CHAT_TRIGGER_NODE_TYPE]; const workflows = await this.workflowService.getWorkflowsWithNodesIncluded( diff --git a/packages/cli/src/modules/chat-hub/context-limits.ts b/packages/cli/src/modules/chat-hub/context-limits.ts index ca3fde834c4..99b82c46bfa 100644 --- a/packages/cli/src/modules/chat-hub/context-limits.ts +++ b/packages/cli/src/modules/chat-hub/context-limits.ts @@ -139,6 +139,7 @@ export const maxContextWindowTokens: Record = { google: 'Google', azureOpenAi: 'Azure OpenAI', ollama: 'Ollama', + awsBedrock: 'AWS Bedrock', n8n: 'n8n', 'custom-agent': 'Custom Agent', }; From ad1e422babe1f1d1776167546e6c8eff44b4f958 Mon Sep 17 00:00:00 2001 From: Robert Squires Date: Wed, 19 Nov 2025 15:24:59 +0000 Subject: [PATCH 04/31] fix(editor): Notice background colors (#22044) --- .../design-system/src/components/N8nNotice/Notice.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/frontend/@n8n/design-system/src/components/N8nNotice/Notice.vue b/packages/frontend/@n8n/design-system/src/components/N8nNotice/Notice.vue index ad37c19a7dd..ae8292e22a0 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nNotice/Notice.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nNotice/Notice.vue @@ -109,13 +109,13 @@ const onClick = (event: MouseEvent) => { } .danger { - --border-color: var(--color--danger--tint-3); - --notice--color--background: var(--color--danger--tint-4); + --border-color: var(--callout--border-color--danger); + --notice--color--background: var(--callout--color--background--danger); } .success { - --border-color: var(--color--success--tint-3); - --notice--color--background: var(--color--success--tint-4); + --border-color: var(--callout--border-color--success); + --notice--color--background: var(--callout--color--background--success); } .info { From eb6cbfc5e3f4e7adf9faf7afcead016333ebd908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Wed, 19 Nov 2025 19:35:13 +0100 Subject: [PATCH 05/31] fix(core): Fix mcp access scope issue (#22031) --- .../cli/src/modules/mcp/mcp.settings.controller.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/modules/mcp/mcp.settings.controller.ts b/packages/cli/src/modules/mcp/mcp.settings.controller.ts index db20f5fa59d..8d5288f4e1d 100644 --- a/packages/cli/src/modules/mcp/mcp.settings.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.settings.controller.ts @@ -1,6 +1,15 @@ import { ModuleRegistry, Logger } from '@n8n/backend-common'; import { type AuthenticatedRequest, WorkflowEntity } from '@n8n/db'; -import { Body, Post, Get, Patch, RestController, GlobalScope, Param } from '@n8n/decorators'; +import { + Body, + Post, + Get, + Patch, + RestController, + GlobalScope, + Param, + ProjectScope, +} from '@n8n/decorators'; import type { Response } from 'express'; import { UpdateMcpSettingsDto } from './dto/update-mcp-settings.dto'; @@ -57,7 +66,7 @@ export class McpSettingsController { return await this.mcpServerApiKeyService.rotateMcpServerApiKey(req.user); } - @GlobalScope('mcp:manage') + @ProjectScope('workflow:update') @Patch('/workflows/:workflowId/toggle-access') async toggleWorkflowMCPAccess( req: AuthenticatedRequest, From 3b74155780ac654dc63a87f289c0ae3ec469f0fe Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Thu, 20 Nov 2025 10:32:08 +0200 Subject: [PATCH 06/31] docs: Add component specification and usage examples for N8nTooltip (#21976) --- .../components/Tooltip/component-tooltip.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Tooltip/component-tooltip.md diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Tooltip/component-tooltip.md b/packages/frontend/@n8n/design-system/src/v2/components/Tooltip/component-tooltip.md new file mode 100644 index 00000000000..26b6ef6868e --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Tooltip/component-tooltip.md @@ -0,0 +1,148 @@ +# Component specification + +Displays contextual information when users hover over, focus on, or tap an element. Tooltips help users understand the purpose or state of UI elements without cluttering the interface. + +- **Component Name:** N8nTooltip +- **Figma Component:** [Figma](https://www.figma.com/design/8zib7Trf2D2CHYXrEGPHkg/n8n-Design-System-V3?node-id=252-3284&m=dev) +- **Element+ Component:** [ElTooltip](https://element-plus.org/en-US/component/tooltip.html) +- **Reka UI Component:** [Tooltip](https://reka-ui.com/docs/components/tooltip) +- **Nuxt UI Component:** [Tooltip](https://ui.nuxt.com/docs/components/tooltip) + + +## Public API Definition + +**Props** + +- `content?: string` - Text content for the tooltip. Supports HTML (sanitized for security). +- `placement?: Placement` - Position of tooltip relative to trigger. Values: `'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end'`. Default: `'top'` +- `disabled?: boolean` - When `true`, prevents the tooltip from showing. +- `showAfter?: number` - Delay in milliseconds before showing tooltip after trigger is hovered. Default: `0` +- `visible?: boolean` - Manual control of tooltip visibility (programmatic show/hide). +- `popperClass?: string` - Custom CSS class name for the tooltip popper element. +- `enterable?: boolean` - Whether the mouse can enter the tooltip content area. Default: `true` +- `popperOptions?: object` - Popper.js configuration object for advanced positioning control. +- `teleported?: boolean` - Whether to append the tooltip to the body. Default: `true` +- `offset?: number` - Offset of the tooltip from the trigger element (in pixels). +- `showArrow?: boolean` - Whether to show the tooltip arrow. Default: `true` + +**Slots** + +- `default` - The trigger element that activates the tooltip (required). +- `content` - Custom content for the tooltip body. When not provided, renders `content` prop with HTML sanitization. + +### Template usage example + +**Simple text tooltip:** +```typescript + + + +``` + +**Custom content slot:** +```typescript + + + +``` + +**Delayed tooltip:** +```typescript + + + +``` + +**Programmatically controlled visibility:** +```typescript + + + +``` + +**Custom popper class:** +```typescript + + + +``` + +**Disabled tooltip:** +```typescript + + + +``` From a22226f5cd56ebb8fdb3fcef97ff00292a0f6830 Mon Sep 17 00:00:00 2001 From: Tuukka Kantola Date: Thu, 20 Nov 2025 10:20:01 +0100 Subject: [PATCH 07/31] chore(editor): Improve agent builder prompt field tooltips (#22045) --- .../src/components/N8nPromptInput/N8nPromptInput.vue | 3 +++ packages/frontend/@n8n/design-system/src/locale/lang/en.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.vue b/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.vue index adfa95eec25..814e2869a3e 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.vue @@ -423,6 +423,7 @@ defineExpose({ @@ -432,6 +433,8 @@ defineExpose({ :disabled="!showAskOwnerTooltip" :content="t('promptInput.askAdminToUpgrade')" placement="top" + :show-after="300" + :enterable="false" > {{ t('promptInput.getMore') }} diff --git a/packages/frontend/@n8n/design-system/src/locale/lang/en.ts b/packages/frontend/@n8n/design-system/src/locale/lang/en.ts index 124823655dc..5db679734b2 100644 --- a/packages/frontend/@n8n/design-system/src/locale/lang/en.ts +++ b/packages/frontend/@n8n/design-system/src/locale/lang/en.ts @@ -86,7 +86,7 @@ export default { 'promptInput.askAdminToUpgrade': 'Ask your admin to upgrade the instance to get more credits', 'promptInput.characterLimitReached': "You've reached the {limit} character limit", 'promptInput.remainingCredits': 'Remaining builder AI credits: {count}', - 'promptInput.monthlyCredits': 'Monthly credits: {count}', + 'promptInput.monthlyCredits': 'Monthly credits: {count} (1 credit = 1 message)', 'promptInput.creditsRenew': 'Credits renew on: {date}', 'promptInput.creditsExpire': 'Unused credits expire {date}', } as N8nLocale; From 8720a0e5f396b4c8df6dd270db54e0841c5f32a5 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Thu, 20 Nov 2025 04:24:03 -0500 Subject: [PATCH 08/31] fix(core): Update state column type (no-changelog) (#22053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Milorad FIlipović --- ...hangeOAuthStateColumnToUnboundedVarchar.ts | 69 +++++++++++++++++++ .../@n8n/db/src/migrations/mysqldb/index.ts | 2 + .../db/src/migrations/postgresdb/index.ts | 2 + .../@n8n/db/src/migrations/sqlite/index.ts | 2 + 4 files changed, 75 insertions(+) create mode 100644 packages/@n8n/db/src/migrations/common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar.ts diff --git a/packages/@n8n/db/src/migrations/common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar.ts b/packages/@n8n/db/src/migrations/common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar.ts new file mode 100644 index 00000000000..7e22cee7502 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar.ts @@ -0,0 +1,69 @@ +import type { IrreversibleMigration, MigrationContext } from '../migration-types'; + +const TABLE_NAME = 'oauth_authorization_codes'; +const TEMP_TABLE_NAME = 'temp_oauth_authorization_codes'; + +export class ChangeOAuthStateColumnToUnboundedVarchar1763572724000 + implements IrreversibleMigration +{ + async up({ + isSqlite, + isMysql, + isPostgres, + escape, + copyTable, + queryRunner, + schemaBuilder: { createTable, column, dropTable }, + }: MigrationContext) { + const tableName = escape.tableName(TABLE_NAME); + + if (isSqlite) { + const tempTableName = escape.tableName(TEMP_TABLE_NAME); + + await createTable(TEMP_TABLE_NAME) + .withColumns( + column('code').varchar(255).primary.notNull, + column('clientId').varchar().notNull, + column('userId').uuid.notNull, + column('redirectUri').varchar().notNull, + column('codeChallenge').varchar().notNull, + column('codeChallengeMethod').varchar(255).notNull, + column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'), + column('state').varchar(), + column('used').bool.notNull.default(false), + ) + .withForeignKey('clientId', { + tableName: 'oauth_clients', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('userId', { + tableName: 'user', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + await copyTable(TABLE_NAME, TEMP_TABLE_NAME); + + await dropTable(TABLE_NAME); + + await queryRunner.query(`ALTER TABLE ${tempTableName} RENAME TO ${tableName};`); + } else if (isMysql) { + await queryRunner.query( + `ALTER TABLE ${tableName} MODIFY COLUMN ${escape.columnName('state')} TEXT;`, + ); + await queryRunner.query( + `ALTER TABLE ${tableName} MODIFY COLUMN ${escape.columnName('codeChallenge')} TEXT NOT NULL;`, + ); + await queryRunner.query( + `ALTER TABLE ${tableName} MODIFY COLUMN ${escape.columnName('redirectUri')} TEXT NOT NULL;`, + ); + } else if (isPostgres) { + await queryRunner.query( + `ALTER TABLE ${tableName} ALTER COLUMN ${escape.columnName('state')} TYPE VARCHAR,` + + ` ALTER COLUMN ${escape.columnName('codeChallenge')} TYPE VARCHAR,` + + ` ALTER COLUMN ${escape.columnName('redirectUri')} TYPE VARCHAR;`, + ); + } + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index abebfdae511..31ce8bf32ef 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -113,6 +113,7 @@ import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-Create import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; +import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import type { Migration } from '../migration-types'; export const mysqlMigrations: Migration[] = [ @@ -231,4 +232,5 @@ export const mysqlMigrations: Migration[] = [ BackfillMissingWorkflowHistoryRecords1762763704614, AddWorkflowHistoryAutoSaveFields1762847206508, AddToolsColumnToChatHubTables1761830340990, + ChangeOAuthStateColumnToUnboundedVarchar1763572724000, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 6e76c106dc7..e6be75d5df5 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -113,6 +113,7 @@ import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340 import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; +import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -231,4 +232,5 @@ export const postgresMigrations: Migration[] = [ ChangeDefaultForIdInUserTable1762771264000, AddWorkflowHistoryAutoSaveFields1762847206508, AddToolsColumnToChatHubTables1761830340990, + ChangeOAuthStateColumnToUnboundedVarchar1763572724000, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 5db7f08d5e7..bf9dffa0f9c 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -109,6 +109,7 @@ import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340 import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; +import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import type { Migration } from '../migration-types'; const sqliteMigrations: Migration[] = [ @@ -223,6 +224,7 @@ const sqliteMigrations: Migration[] = [ BackfillMissingWorkflowHistoryRecords1762763704614, AddWorkflowHistoryAutoSaveFields1762847206508, AddToolsColumnToChatHubTables1761830340990, + ChangeOAuthStateColumnToUnboundedVarchar1763572724000, ]; export { sqliteMigrations }; From 34039b370b4d29831f3e8d25a6b89ac3dcc963ff Mon Sep 17 00:00:00 2001 From: Konstantin Tieber <46342664+konstantintieber@users.noreply.github.com> Date: Thu, 20 Nov 2025 10:40:39 +0100 Subject: [PATCH 09/31] feat(core): Move settings for SSO user role provisioning from dedicated page to existing form (#21901) --- .../frontend/@n8n/i18n/src/locales/en.json | 25 +- .../src/app/components/SettingsSidebar.vue | 15 +- .../editor-ui/src/app/constants/navigation.ts | 1 - .../frontend/editor-ui/src/app/router.test.ts | 36 +- packages/frontend/editor-ui/src/app/router.ts | 43 +- .../EnableJitProvisioningDialog.vue | 165 ----- .../views/SettingsProvisioningView.vue | 250 -------- .../sso/components/OidcSettingsForm.vue | 302 +++++++++ .../sso/components/SamlSettingsForm.vue | 338 ++++++++++ .../components/ConfirmProvisioningDialog.vue | 265 ++++++++ .../UserRoleProvisioningDropdown.vue | 145 +++++ .../composables/useAccessSettingsCsvExport.ts | 0 .../useUserRoleProvisioningForm.ts | 76 +++ .../userRoleProvisioning.store.ts} | 17 +- .../settings/sso/styles/sso-form.module.scss | 48 ++ .../settings/sso/views/SettingsSso.test.ts | 29 +- .../settings/sso/views/SettingsSso.vue | 600 +----------------- 17 files changed, 1244 insertions(+), 1111 deletions(-) delete mode 100644 packages/frontend/editor-ui/src/features/settings/provisioning/components/EnableJitProvisioningDialog.vue delete mode 100644 packages/frontend/editor-ui/src/features/settings/provisioning/views/SettingsProvisioningView.vue create mode 100644 packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue create mode 100644 packages/frontend/editor-ui/src/features/settings/sso/components/SamlSettingsForm.vue create mode 100644 packages/frontend/editor-ui/src/features/settings/sso/provisioning/components/ConfirmProvisioningDialog.vue create mode 100644 packages/frontend/editor-ui/src/features/settings/sso/provisioning/components/UserRoleProvisioningDropdown.vue rename packages/frontend/editor-ui/src/features/settings/{ => sso}/provisioning/composables/useAccessSettingsCsvExport.ts (100%) create mode 100644 packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.ts rename packages/frontend/editor-ui/src/features/settings/{provisioning/provisioning.store.ts => sso/provisioning/composables/userRoleProvisioning.store.ts} (76%) create mode 100644 packages/frontend/editor-ui/src/features/settings/sso/styles/sso-form.module.scss diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index e9e7daba1ec..dfc4c51a4f6 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2509,12 +2509,24 @@ "settings.provisioning.scopesProjectsRolesClaimName.help": "The claim name used to provision projects and their roles from Oauth. For SAML / LDAP, this will be the attribute name checked.", "settings.provisioning.toggle": "Provision instance and project roles", "settings.provisioning.toggle.help": "Project access can only be defined on external provider. Any existing project access configured in n8n, but not on the provider, will be removed once a user logs in.", - "settings.provisioningConfirmDialog.title": "Enable Just-in-time provisioning (JIT)", + "settings.provisioningConfirmDialog.enable.title": "Enable user role provisioning", + "settings.provisioningConfirmDialog.disable.title": "Disable user role provisioning", "settings.provisioningConfirmDialog.breakingChangeDescription.firstLine": "When you enable Just-in-time provisioning, your external SSO provider becomes the source of truth for all instance and project roles in n8n.", "settings.provisioningConfirmDialog.breakingChangeDescription.list.one": "If your SSO provider doesn't specify a role for a member, we'll automatically assign the default role: global:member.", "settings.provisioningConfirmDialog.breakingChangeDescription.list.two": "Any existing instance and project roles in n8n will be replaced by the roles defined in your SSO provider once the user logs in via SSO.", "settings.provisioningConfirmDialog.breakingChangeRequiredSteps": "To enable you to migrate your current access settings to your SSO provider, download the two CSV files below. This step is mandatory before enabling JIT.", - "settings.provisioningConfirmDialog.button.confirm": "Activate JIT", + "settings.provisioningConfirmDialog.disable.description": "You're switching instance role management back to n8n.", + "settings.provisioningConfirmDialog.disable.whatWillHappen": "What will happen:", + "settings.provisioningConfirmDialog.disable.list.one": "The SSO n8n_instance_role attribute will be ignored.", + "settings.provisioningConfirmDialog.disable.list.two": "Instance roles must be reassigned manually inside n8n.", + "settings.provisioningConfirmDialog.disable.beforeSaving": "Before saving, make sure:", + "settings.provisioningConfirmDialog.disable.checklist.one": "You are ready to reassign instance roles for all users inside n8n.", + "settings.provisioningConfirmDialog.disable.checklist.two": "You understand that role changes made in SSO will no longer be applied.", + "settings.provisioningConfirmDialog.enable.checkbox": "I have downloaded and reviewed the CSV export. My SSO provider is correctly configured to become the source of truth for user role provisioning on this n8n instance.", + "settings.provisioningConfirmDialog.disable.checkbox": "I confirm that I want to no longer provision user roles from my SSO provider.", + "settings.provisioningConfirmDialog.link.docs": "Link to docs", + "settings.provisioningConfirmDialog.button.enable.confirm": "Save and enable", + "settings.provisioningConfirmDialog.button.disable.confirm": "Save and disable", "settings.provisioningConfirmDialog.button.cancel": "Cancel", "settings.provisioningConfirmDialog.button.generateCsvExport": "Generate access settings CSV export", "settings.provisioningConfirmDialog.button.downloadProjectRolesCsv": "Download existing project access settings csv", @@ -3442,6 +3454,15 @@ "settings.sso.settings.oidc.prompt.consent": "Consent (Ask the user to consent)", "settings.sso.settings.oidc.prompt.select_account": "Select Account (Allow the user to select an account)", "settings.sso.settings.oidc.prompt.create": "Create (Ask the OP to show the registration page first)", + "settings.sso.settings.userRoleProvisioning.label": "User role provisioning", + "settings.sso.settings.userRoleProvisioning.help": "Manage instance and project roles from your SSO provider.", + "settings.sso.settings.userRoleProvisioning.help.linkText": "Link to docs", + "settings.sso.settings.userRoleProvisioning.option.disabled.label": "Disabled", + "settings.sso.settings.userRoleProvisioning.option.disabled.description": "User and project roles are managed inside the n8n settings.", + "settings.sso.settings.userRoleProvisioning.option.instanceRole.label": "Instance role", + "settings.sso.settings.userRoleProvisioning.option.instanceRole.description": "The instance role of a user is configured in the \"n8n_instance_role\" attribute on your SSO provider. If none is set on the SSO provider, the member role is used as fallback.", + "settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.label": "Instance and project roles", + "settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.description": "The list of projects a user has access to is configured on the \"n8n_projects\" string array attribute on your SSO provider. Project access cannot be granted from within n8n.", "settings.sso.settings.test": "Test settings", "settings.sso.settings.save": "Save settings", "settings.sso.settings.save.activate.title": "Test and activate SAML SSO", diff --git a/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue b/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue index c6f833de49f..66eade0ca3e 100644 --- a/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue +++ b/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue @@ -1,7 +1,6 @@ - - - diff --git a/packages/frontend/editor-ui/src/features/settings/provisioning/views/SettingsProvisioningView.vue b/packages/frontend/editor-ui/src/features/settings/provisioning/views/SettingsProvisioningView.vue deleted file mode 100644 index 1a20e78946e..00000000000 --- a/packages/frontend/editor-ui/src/features/settings/provisioning/views/SettingsProvisioningView.vue +++ /dev/null @@ -1,250 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue b/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue new file mode 100644 index 00000000000..f7b3ac124a4 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue @@ -0,0 +1,302 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/settings/sso/provisioning/components/UserRoleProvisioningDropdown.vue b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/components/UserRoleProvisioningDropdown.vue new file mode 100644 index 00000000000..d8e364d1a87 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/components/UserRoleProvisioningDropdown.vue @@ -0,0 +1,145 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/settings/provisioning/composables/useAccessSettingsCsvExport.ts b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useAccessSettingsCsvExport.ts similarity index 100% rename from packages/frontend/editor-ui/src/features/settings/provisioning/composables/useAccessSettingsCsvExport.ts rename to packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useAccessSettingsCsvExport.ts diff --git a/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.ts b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.ts new file mode 100644 index 00000000000..610c539a7ae --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.ts @@ -0,0 +1,76 @@ +import type { Ref } from 'vue'; +import { useUserRoleProvisioningStore } from './userRoleProvisioning.store'; +import { usePostHog } from '@/app/stores/posthog.store'; +import { SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT } from '@/app/constants/experiments'; +import type { ProvisioningConfig } from '@n8n/rest-api-client/api/provisioning'; +import { type UserRoleProvisioningSetting } from '../components/UserRoleProvisioningDropdown.vue'; + +/** + * Composable for managing user role provisioning form logic in SSO settings. + */ +export function useUserRoleProvisioningForm( + userRoleProvisioning: Ref, +) { + const provisioningStore = useUserRoleProvisioningStore(); + const posthogStore = usePostHog(); + + const getUserRoleProvisioningValueFromConfig = ( + config?: ProvisioningConfig, + ): UserRoleProvisioningSetting => { + if (!config) { + return 'disabled'; + } + if (config.scopesProvisionInstanceRole && config.scopesProvisionProjectRoles) { + return 'instance_and_project_roles'; + } else if (config.scopesProvisionInstanceRole) { + return 'instance_role'; + } else { + return 'disabled'; + } + }; + + const getProvisioningConfigFromFormValue = ( + formValue: UserRoleProvisioningSetting, + ): Pick => { + if (formValue === 'instance_role') { + return { + scopesProvisionInstanceRole: true, + scopesProvisionProjectRoles: false, + }; + } else if (formValue === 'instance_and_project_roles') { + return { + scopesProvisionInstanceRole: true, + scopesProvisionProjectRoles: true, + }; + } else { + return { + scopesProvisionInstanceRole: false, + scopesProvisionProjectRoles: false, + }; + } + }; + + const isUserRoleProvisioningChanged = (): boolean => { + if (!posthogStore.isFeatureEnabled(SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name)) { + return false; + } + return ( + getUserRoleProvisioningValueFromConfig(provisioningStore.provisioningConfig) !== + userRoleProvisioning.value + ); + }; + + /** + * Saves the current user role provisioning setting to the store. + */ + const saveProvisioningConfig = async (): Promise => { + await provisioningStore.saveProvisioningConfig( + getProvisioningConfigFromFormValue(userRoleProvisioning.value), + ); + }; + + return { + isUserRoleProvisioningChanged, + saveProvisioningConfig, + }; +} diff --git a/packages/frontend/editor-ui/src/features/settings/provisioning/provisioning.store.ts b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/userRoleProvisioning.store.ts similarity index 76% rename from packages/frontend/editor-ui/src/features/settings/provisioning/provisioning.store.ts rename to packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/userRoleProvisioning.store.ts index f0461df5dd5..fd1aeb734ff 100644 --- a/packages/frontend/editor-ui/src/features/settings/provisioning/provisioning.store.ts +++ b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/userRoleProvisioning.store.ts @@ -1,21 +1,17 @@ -import { computed, ref } from 'vue'; +import { ref, readonly } from 'vue'; import { defineStore } from 'pinia'; import { useRootStore } from '@n8n/stores/useRootStore'; import * as provisioningApi from '@n8n/rest-api-client/api/provisioning'; import type { ProvisioningConfig } from '@n8n/rest-api-client/api/provisioning'; -export const useProvisioningStore = defineStore('provisioning', () => { +/** + * Composable to load and save provisioning config + */ +export const useUserRoleProvisioningStore = defineStore('userRoleProvisioning', () => { const rootStore = useRootStore(); const provisioningConfig = ref(); - const isProvisioningEnabled = computed( - () => - provisioningConfig.value?.scopesProvisionInstanceRole || - provisioningConfig.value?.scopesProvisionProjectRoles || - false, - ); - const getProvisioningConfig = async () => { try { const config = await provisioningApi.getProvisioningConfig(rootStore.restApiContext); @@ -42,8 +38,7 @@ export const useProvisioningStore = defineStore('provisioning', () => { }; return { - provisioningConfig, - isProvisioningEnabled, + provisioningConfig: readonly(provisioningConfig), getProvisioningConfig, saveProvisioningConfig, }; diff --git a/packages/frontend/editor-ui/src/features/settings/sso/styles/sso-form.module.scss b/packages/frontend/editor-ui/src/features/settings/sso/styles/sso-form.module.scss new file mode 100644 index 00000000000..f90a1d7c2d3 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/sso/styles/sso-form.module.scss @@ -0,0 +1,48 @@ +/** + * Shared styles for SSO forms + */ + +.switch { + span { + font-size: var(--font-size--2xs); + font-weight: var(--font-weight--bold); + color: var(--color--text--tint-1); + } +} + +.buttons { + display: flex; + justify-content: flex-start; + padding: var(--spacing--2xl) 0 var(--spacing--2xs); + + button { + margin: 0 var(--spacing--sm) 0 0; + } +} + +.group { + padding: var(--spacing--xl) 0 0; + + > label { + display: inline-block; + font-size: var(--font-size--sm); + font-weight: var(--font-weight--medium); + padding: 0 0 var(--spacing--2xs); + } + + small { + display: block; + padding: var(--spacing--2xs) 0 0; + font-size: var(--font-size--2xs); + color: var(--color--text); + } +} + +.actionBox { + margin: var(--spacing--2xl) 0 0; +} + +.footer { + color: var(--color--text); + font-size: var(--font-size--2xs); +} diff --git a/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.test.ts b/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.test.ts index 9c3de9d3686..d903e8fc1f5 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.test.ts @@ -138,6 +138,15 @@ describe('SettingsSso View', () => { ssoStore.isEnterpriseSamlEnabled = true; ssoStore.isEnterpriseOidcEnabled = true; + ssoStore.isSamlLoginEnabled = false; + ssoStore.samlConfig = { ...samlConfig, metadataUrl: undefined, metadata: undefined }; + ssoStore.getSamlConfig.mockResolvedValue({ + ...samlConfig, + metadataUrl: undefined, + metadata: undefined, + }); + ssoStore.saveSamlConfig.mockResolvedValue({ ...samlConfig, metadata: undefined }); + ssoStore.testSamlConfig.mockResolvedValue('https://test-url.com'); const { getByTestId } = renderView(); @@ -174,6 +183,11 @@ describe('SettingsSso View', () => { ssoStore.isEnterpriseSamlEnabled = true; ssoStore.isEnterpriseOidcEnabled = true; + ssoStore.isSamlLoginEnabled = false; + ssoStore.samlConfig = { ...samlConfig, metadataUrl: undefined, metadata: undefined }; + // Mock should return config with metadata but WITHOUT metadataUrl (since user filled XML) + ssoStore.saveSamlConfig.mockResolvedValue({ ...samlConfig, metadataUrl: undefined }); + ssoStore.testSamlConfig.mockResolvedValue('https://test-url.com'); const { getByTestId } = renderView(); @@ -229,7 +243,8 @@ describe('SettingsSso View', () => { expect(telemetryTrack).not.toHaveBeenCalled(); - expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(2); + // getSamlConfig only called once (on mount) since save failed validation + expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(1); }); it('should ensure the url does not support invalid protocols like mailto', async () => { @@ -256,7 +271,8 @@ describe('SettingsSso View', () => { expect(telemetryTrack).not.toHaveBeenCalled(); - expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(2); + // getSamlConfig only called once (on mount) since save failed validation + expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(1); }); it('allows user to disable SSO even if config request failed', async () => { @@ -325,16 +341,17 @@ describe('SettingsSso View', () => { }); it('allows user to save OIDC config', async () => { - ssoStore.saveOidcConfig.mockResolvedValue(oidcConfig); ssoStore.isEnterpriseOidcEnabled = true; ssoStore.isEnterpriseSamlEnabled = false; ssoStore.isOidcLoginEnabled = true; ssoStore.isSamlLoginEnabled = false; + ssoStore.oidcConfig = { ...oidcConfig, discoveryEndpoint: '' }; ssoStore.getOidcConfig.mockResolvedValue({ ...oidcConfig, discoveryEndpoint: '', }); + ssoStore.saveOidcConfig.mockResolvedValue({ ...oidcConfig, loginEnabled: true }); const { getByTestId, getByRole } = renderView(); @@ -367,6 +384,12 @@ describe('SettingsSso View', () => { await userEvent.type(clientSecretInput, 'test-client-secret'); expect(saveButton).not.toBeDisabled(); + + // Pinia mocked stores don't execute real store logic. In production, saveOidcConfig + // updates oidcConfig.value (sso.store.ts:144), but the mock just returns a value. + // We manually update the store to match what the real store would do. + ssoStore.oidcConfig = oidcConfig; + await userEvent.click(saveButton); expect(ssoStore.saveOidcConfig).toHaveBeenCalledWith( diff --git a/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.vue b/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.vue index 82da078fc79..f3bf8b5c863 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.vue +++ b/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.vue @@ -1,53 +1,16 @@ + From db16933c5e458cff6fe373670a26fa73a41d7450 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Thu, 20 Nov 2025 11:11:56 +0100 Subject: [PATCH 10/31] chore(core): Add instanceType condition to load insights module only for main aand webhook (#22052) --- .../insights/__tests__/insights.module.test.ts | 15 ++++++++++++++- .../cli/src/modules/insights/insights.module.ts | 13 +++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/modules/insights/__tests__/insights.module.test.ts b/packages/cli/src/modules/insights/__tests__/insights.module.test.ts index 865473eda26..10d1246d5ba 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.module.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.module.test.ts @@ -7,6 +7,7 @@ import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; import { InsightsModule } from '../insights.module'; +import { InsightsService } from '../insights.service'; describe('InsightsModule', () => { let insightsModule: InsightsModule; @@ -23,11 +24,23 @@ describe('InsightsModule', () => { beforeEach(async () => { jest.clearAllMocks(); await testDb.truncate(['Project']); - insightsModule = Container.get(InsightsModule); + mockInstanceSettings = mock(); Container.set(InstanceSettings, mockInstanceSettings); Container.set(Logger, mockLogger()); Container.set(LicenseState, mock()); + Container.set( + InsightsService, + new InsightsService( + mock(), + mock(), + mock(), + Container.get(LicenseState), + mockInstanceSettings, + Container.get(Logger), + ), + ); + insightsModule = Container.get(InsightsModule); await createTeamProject(); }); diff --git a/packages/cli/src/modules/insights/insights.module.ts b/packages/cli/src/modules/insights/insights.module.ts index af495da88d4..3ba880b2b19 100644 --- a/packages/cli/src/modules/insights/insights.module.ts +++ b/packages/cli/src/modules/insights/insights.module.ts @@ -1,17 +1,14 @@ import type { ModuleInterface } from '@n8n/decorators'; import { BackendModule, OnShutdown } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { InstanceSettings } from 'n8n-core'; -@BackendModule({ name: 'insights' }) +/** + * Only main- and webhook-type instances collect insights because + * only they are informed of finished workflow executions. + */ +@BackendModule({ name: 'insights', instanceTypes: ['main', 'webhook'] }) export class InsightsModule implements ModuleInterface { async init() { - /** - * Only main- and webhook-type instances collect insights because - * only they are informed of finished workflow executions. - */ - if (Container.get(InstanceSettings).instanceType === 'worker') return; - await import('./insights.controller'); const { InsightsService } = await import('./insights.service'); From 4232093bb95a663c04f1796246d38bc96ea9ffea Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Thu, 20 Nov 2025 12:56:44 +0200 Subject: [PATCH 11/31] docs: Add component specification and usage examples for N8nBadge (#22049) --- .../v2/components/Badge/component-badge.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Badge/component-badge.md diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Badge/component-badge.md b/packages/frontend/@n8n/design-system/src/v2/components/Badge/component-badge.md new file mode 100644 index 00000000000..07cd48b9388 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Badge/component-badge.md @@ -0,0 +1,220 @@ +# Component specification + +Displays short text or icons to represent status, categories, or counts. Badges help users quickly identify important information through visual indicators without cluttering the interface. + +- **Component Name:** N8nBadge +- **Figma Component:** TBD +- **Current Implementation:** Custom component (no Element+ dependency) +- **Reka UI Component:** N/A (Reka UI does not provide a Badge primitive) +- **Nuxt UI Reference:** [Badge](https://ui.nuxt.com/docs/components/badge) (custom implementation, not based on Reka UI) + +## Public API Definition + +**Props** + +- `theme?: BadgeTheme` - Visual style variant for the badge. Values: `'default' | 'success' | 'warning' | 'danger' | 'primary' | 'secondary' | 'tertiary'`. Default: `'default'` + - `default`: Subtle border badge with neutral styling + - `success`: Green indicator for positive states + - `warning`: Yellow/orange indicator for cautionary states + - `danger`: Red indicator for error/critical states + - `primary`: Filled badge with contrasting text (pill-shaped) + - `secondary`: Filled badge with tinted background + - `tertiary`: Minimal badge variant with reduced padding +- `size?: TextSize` - Size of the badge text. Values: `'xsmall' | 'small' | 'mini' | 'medium' | 'large' | 'xlarge'`. Default: `'small'` +- `bold?: boolean` - Whether to render text in bold weight. Default: `false` +- `showBorder?: boolean` - Whether to show the badge border. Default: `true` + +**Slots** + +- `default` - Badge content (text, icons, or mixed content). Wrapped in N8nText component for consistent typography. + +### Template usage examples + +**Simple text badge:** +```typescript + + + +``` + +**Status indicators:** +```typescript + + + +``` + +**Count badge (primary theme):** +```typescript + + + +``` + +**Tertiary minimal label:** +```typescript + + + +``` + +**Badge with icon and text:** +```typescript + + + +``` + +**Multiple sizes:** +```typescript + + + +``` + +**With custom CSS classes:** +```typescript + + + +``` + +**Conditional rendering:** +```typescript + + + +``` + +**With inline styles (advanced):** +```typescript + + + +``` + +**Dynamic theme binding:** +```typescript + + + +``` From dcea7a9d5f41dd239091940845f2712187982ef0 Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Thu, 20 Nov 2025 12:26:02 +0100 Subject: [PATCH 12/31] chore(core): Interface definition for context establishment hooks (#22073) --- packages/@n8n/decorators/package.json | 1 + .../context-establishment-hook.test.ts | 148 ++++++++ .../context-establishment-hook-metadata.ts | 200 +++++++++++ .../context-establishment-hook.ts | 328 ++++++++++++++++++ .../src/context-establishment/index.ts | 5 + packages/workflow/src/execution-context.ts | 49 +++ 6 files changed, 731 insertions(+) create mode 100644 packages/@n8n/decorators/src/context-establishment/__tests__/context-establishment-hook.test.ts create mode 100644 packages/@n8n/decorators/src/context-establishment/context-establishment-hook-metadata.ts create mode 100644 packages/@n8n/decorators/src/context-establishment/context-establishment-hook.ts create mode 100644 packages/@n8n/decorators/src/context-establishment/index.ts diff --git a/packages/@n8n/decorators/package.json b/packages/@n8n/decorators/package.json index 89cff5978ce..e45fc61d28c 100644 --- a/packages/@n8n/decorators/package.json +++ b/packages/@n8n/decorators/package.json @@ -12,6 +12,7 @@ "lint:fix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "jest", + "test:unit": "jest", "test:dev": "jest --watch" }, "main": "dist/index.js", diff --git a/packages/@n8n/decorators/src/context-establishment/__tests__/context-establishment-hook.test.ts b/packages/@n8n/decorators/src/context-establishment/__tests__/context-establishment-hook.test.ts new file mode 100644 index 00000000000..7da6738799f --- /dev/null +++ b/packages/@n8n/decorators/src/context-establishment/__tests__/context-establishment-hook.test.ts @@ -0,0 +1,148 @@ +import { Container } from '@n8n/di'; + +import type { + ContextEstablishmentOptions, + ContextEstablishmentResult, + IContextEstablishmentHook, +} from '../context-establishment-hook'; +import { + ContextEstablishmentHookMetadata, + ContextEstablishmentHook, +} from '../context-establishment-hook-metadata'; + +describe('@ContextEstablishmentHook decorator', () => { + let hookMetadata: ContextEstablishmentHookMetadata; + + beforeEach(() => { + jest.resetAllMocks(); + + hookMetadata = new ContextEstablishmentHookMetadata(); + Container.set(ContextEstablishmentHookMetadata, hookMetadata); + }); + + it('should register hook in ContextEstablishmentHookMetadata', () => { + @ContextEstablishmentHook() + class TestHook implements IContextEstablishmentHook { + hookDescription = { name: 'test.hook' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + const registeredHooks = hookMetadata.getClasses(); + + expect(registeredHooks).toContain(TestHook); + expect(registeredHooks).toHaveLength(1); + }); + + it('should register multiple hooks', () => { + @ContextEstablishmentHook() + class FirstHook implements IContextEstablishmentHook { + hookDescription = { name: 'first.hook' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + @ContextEstablishmentHook() + class SecondHook implements IContextEstablishmentHook { + hookDescription = { name: 'second.hook' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + @ContextEstablishmentHook() + class ThirdHook implements IContextEstablishmentHook { + hookDescription = { name: 'third.hook' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + const registeredHooks = hookMetadata.getClasses(); + + expect(registeredHooks).toContain(FirstHook); + expect(registeredHooks).toContain(SecondHook); + expect(registeredHooks).toContain(ThirdHook); + expect(registeredHooks).toHaveLength(3); + }); + + it('should apply Service decorator', () => { + @ContextEstablishmentHook() + class TestHook implements IContextEstablishmentHook { + hookDescription = { name: 'test.hook' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + expect(Container.has(TestHook)).toBe(true); + }); + + it('should allow instantiation of registered hooks with accessible hookDescription', () => { + @ContextEstablishmentHook() + class TestHook implements IContextEstablishmentHook { + hookDescription = { name: 'credentials.bearerToken' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + const hookInstance = Container.get(TestHook); + + expect(hookInstance).toBeInstanceOf(TestHook); + expect(hookInstance.hookDescription).toEqual({ name: 'credentials.bearerToken' }); + expect(hookInstance.hookDescription.name).toBe('credentials.bearerToken'); + }); + + it('should register hooks with different description names', () => { + @ContextEstablishmentHook() + class BearerTokenHook implements IContextEstablishmentHook { + hookDescription = { name: 'credentials.bearerToken' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + @ContextEstablishmentHook() + class ApiKeyHook implements IContextEstablishmentHook { + hookDescription = { name: 'credentials.apiKey' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + const registeredHooks = hookMetadata.getClasses(); + const bearerTokenHook = Container.get(BearerTokenHook); + const apiKeyHook = Container.get(ApiKeyHook); + + expect(registeredHooks).toHaveLength(2); + expect(bearerTokenHook.hookDescription.name).toBe('credentials.bearerToken'); + expect(apiKeyHook.hookDescription.name).toBe('credentials.apiKey'); + }); +}); diff --git a/packages/@n8n/decorators/src/context-establishment/context-establishment-hook-metadata.ts b/packages/@n8n/decorators/src/context-establishment/context-establishment-hook-metadata.ts new file mode 100644 index 00000000000..e26dfb1e749 --- /dev/null +++ b/packages/@n8n/decorators/src/context-establishment/context-establishment-hook-metadata.ts @@ -0,0 +1,200 @@ +import { Container, Service } from '@n8n/di'; + +import { ContextEstablishmentHookClass } from './context-establishment-hook'; + +/** + * Registry entry for a context establishment hook. + * + * This is a lightweight wrapper around the hook class constructor that can be + * extended in the future to include additional metadata if needed (e.g., module + * source, registration timestamp, feature flags, license flags). + * + * @internal + */ +type ContextEstablishmentHookEntry = { + /** The hook class constructor for DI container instantiation */ + class: ContextEstablishmentHookClass; +}; + +/** + * Low-level metadata registry for context establishment hooks. + * + * This service acts as a simple collection of registered hook classes that gets + * populated automatically by the @ContextEstablishmentHook decorator at module + * load time. It serves as the foundation for the higher-level Hook Registry. + * + * **Architecture:** + * ``` + * Decorator → ContextEstablishmentHookMetadata → Hook Registry → Execution Engine + * (registration) (collection) (discovery) (execution) + * ``` + * + * @see ContextEstablishmentHook decorator for automatic registration + * @see IContextEstablishmentHook for hook interface + */ +@Service() +export class ContextEstablishmentHookMetadata { + /** + * Internal collection of registered hook classes. + * + * Uses Set for efficient deduplication (though duplicate registration + * should not occur with proper decorator usage). + */ + private readonly contextEstablishmentHooks: Set = new Set(); + + /** + * Registers a hook class in the metadata collection. + * + * Called automatically by the @ContextEstablishmentHook decorator during + * module loading. Should not be called directly by application code. + * + * **Note:** This method does not validate uniqueness or check for naming + * conflicts. Validation happens later in the Hook Registry. + * + * @param hookEntry - The hook class entry to register + * + * @internal Called by decorator only + */ + register(hookEntry: ContextEstablishmentHookEntry) { + this.contextEstablishmentHooks.add(hookEntry); + } + + /** + * Retrieves all registered hook entries. + * + * Returns an array of [index, entry] tuples compatible with Set.entries(). + * Primarily used for debugging or low-level iteration. + * + * **Prefer getClasses()** for most use cases as it returns just the classes. + * + * @returns Array of [index, entry] tuples from the internal Set + * + * @example + * ```typescript + * const entries = metadata.getEntries(); + * for (const [index, entry] of entries) { + * console.log(`Hook ${index}:`, entry.class.name); + * } + * ``` + */ + getEntries() { + return [...this.contextEstablishmentHooks.entries()]; + } + + /** + * Retrieves all registered hook classes. + * + * This is the primary method used by the Hook Registry to obtain hook classes + * for instantiation and indexing. Returns just the class constructors without + * the wrapper entry objects. + * + * **Usage pattern:** + * ```typescript + * const classes = metadata.getClasses(); + * const hooks = classes.map(HookClass => Container.get(HookClass)); + * const hooksByName = new Map(hooks.map(h => [h.hookDescription.name, h])); + * ``` + * + * @returns Array of hook class constructors ready for DI instantiation + * + * @example + * ```typescript + * @Service() + * export class HookRegistry { + * constructor( + * private metadata: ContextEstablishmentHookMetadata, + * private container: Container + * ) { + * const hookClasses = metadata.getClasses(); + * this.hooks = hookClasses.map(cls => container.get(cls)); + * } + * } + * ``` + */ + getClasses() { + return [...this.contextEstablishmentHooks.values()].map((entry) => entry.class); + } +} + +/** + * Class decorator for context establishment hooks. + * + * This decorator performs two critical functions: + * 1. **Registers** the hook class in ContextEstablishmentHookMetadata for discovery + * 2. **Enables DI** by applying @Service() to make the hook injectable + * + * The decorator executes at module load time (when the class is defined), ensuring + * all hooks are registered before the application starts. This enables automatic + * discovery without manual registration code. + * + * **Registration flow:** + * ``` + * @ContextEstablishmentHook() // 1. Decorator executes + * export class BearerTokenHook // 2. Class is defined + * ↓ + * ContextEstablishmentHookMetadata // 3. Hook class registered in metadata + * ↓ + * @Service() // 4. DI container registration + * ↓ + * Hook is discoverable & injectable // 5. Ready for use + * ``` + * + * **Design pattern:** + * This follows the declarative registration pattern used throughout n8n for + * extensibility (similar to node registration). Hooks self-register without + * requiring central registration files or manual imports. + * + * **Requirements:** + * - Decorated class MUST implement IContextEstablishmentHook + * - Decorated class MUST have a hookDescription property with unique name + * + * **Important notes:** + * - No decorator parameters needed (hook metadata lives on hook instance) + * - Hooks are registered as singletons via @Service() + * - Registration happens eagerly at module load, not lazily + * - Duplicate decoration of the same class is safe (Set deduplicates) + * + * @see IContextEstablishmentHook for interface requirements + * @see ContextEstablishmentHookMetadata for underlying registry + * @see HookDescription for hook metadata structure + * + * @example + * ```typescript + * // Basic hook registration: + * @ContextEstablishmentHook() + * export class BearerTokenHook implements IContextEstablishmentHook { + * hookDescription = { + * name: 'credentials.bearerToken' + * }; + * + * async execute(options: ContextEstablishmentOptions) { + * // Extract bearer token from Authorization header + * const token = this.extractToken(options.triggerItem); + * return { + * triggerItem: this.removeAuthHeader(options.triggerItem), + * contextUpdate: { + * credentials: { version: 1, identity: token } + * } + * }; + * } + * + * isApplicableToTriggerNode(nodeType: string) { + * return nodeType === 'n8n-nodes-base.webhook'; + * } + * } + * ``` + * + * @returns A class decorator function that registers and enables DI for the hook + */ +export const ContextEstablishmentHook = + () => + (target: T) => { + // Register hook class in metadata for discovery by Hook Registry + Container.get(ContextEstablishmentHookMetadata).register({ + class: target, + }); + + // Enable dependency injection for the hook class + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Service()(target); + }; diff --git a/packages/@n8n/decorators/src/context-establishment/context-establishment-hook.ts b/packages/@n8n/decorators/src/context-establishment/context-establishment-hook.ts new file mode 100644 index 00000000000..15efaa1ef87 --- /dev/null +++ b/packages/@n8n/decorators/src/context-establishment/context-establishment-hook.ts @@ -0,0 +1,328 @@ +import type { Constructable } from '@n8n/di'; +import type { + INode, + INodeExecutionData, + PlaintextExecutionContext, + IWorkflowBase, +} from 'n8n-workflow'; + +/** + * Input parameters passed to a context establishment hook during execution. + * + * Hooks receive the current workflow state and extract information from + * trigger items to build the execution context (e.g., credentials, environment). + * All hooks work with plaintext (decrypted) context for runtime operations. + * + * @see IContextEstablishmentHook + * @see PlaintextExecutionContext + */ +export type ContextEstablishmentOptions = { + /** The trigger node that initiated the workflow execution */ + triggerNode: INode; + + /** The complete workflow definition */ + workflow: IWorkflowBase; + + /** + * Trigger items from the workflow execution start. + * This array represents items as modified by previous hooks in the chain. + * Hooks can extract data from these items and optionally modify them + * (e.g., removing sensitive headers before storage). + */ + triggerItems: INodeExecutionData[]; + + /** + * The plaintext execution context built so far. + * Includes base context plus results from any previously executed hooks. + * Contains decrypted credential data for runtime operations. + * + * @see PlaintextExecutionContext for security considerations + */ + context: PlaintextExecutionContext; + + /** + * Hook-specific configuration provided by the trigger node. + * Structure varies per hook type (e.g., { removeFromItem: true } for bearer token hook). + */ + options?: Record; +}; + +/** + * Result returned by a context establishment hook after execution. + * + * Hooks can modify trigger items (e.g., remove sensitive headers) and + * contribute partial context updates that get merged into the execution context. + * All context data is in plaintext form during hook execution. + * + * @see IContextEstablishmentHook + * @see PlaintextExecutionContext + */ +export type ContextEstablishmentResult = { + /** + * The potentially modified trigger items. + * If undefined, the original trigger items are preserved unchanged. + * + * Common use case: Removing sensitive data (e.g., Authorization headers) + * before storing items in execution history. + * + * @example + * ```typescript + * // Remove Authorization header from trigger items + * const modifiedItems = options.triggerItems.map(item => ({ + * ...item, + * json: { + * ...item.json, + * headers: { + * ...item.json.headers, + * authorization: undefined + * } + * } + * })); + * return { triggerItems: modifiedItems, contextUpdate: { ... } }; + * ``` + */ + triggerItems?: INodeExecutionData[]; + + /** + * Partial context update to merge into the execution context. + * If undefined, no context updates are applied. + * + * Contains only this hook's contributions (e.g., credentials data). + * Multiple hooks' updates are merged sequentially during execution. + * Context data is in plaintext form and will be encrypted before persistence. + * + * @example + * ```typescript + * // Add credential context from bearer token + * return { + * triggerItems: modifiedItems, + * contextUpdate: { + * credentials: { + * version: 1, + * identity: extractedToken, + * metadata: { source: 'bearer-token' } + * } + * } + * }; + * ``` + */ + contextUpdate?: Partial; +}; + +/** + * Metadata describing a context establishment hook. + * + * This object carries self-describing information about the hook that enables + * runtime discovery, lookup, and instantiation. Each hook instance serves as + * the single source of truth for its own metadata. + * + * **Design rationale:** + * - Hook instances are self-describing (no external configuration files) + * - Name lookup happens at runtime via Registry, not during registration + * - Description can be extended without changing decorator or registry internals + * - Supports future features like versioning, schema validation, and categorization + * + * **Future extensions** may include: + * - `version?: string` - Semantic version of hook implementation for compatibility checks + * - `configSchema?: ZodSchema` - Validation schema for hook-specific options + * - `tags?: string[]` - Categorization tags for grouping and filtering + * - `applicableTriggers?: string[]` - Cached list of compatible trigger node types + * - `deprecated?: boolean | string` - Deprecation status and migration guidance + * + * @see IContextEstablishmentHook.hookDescription + * @see ContextEstablishmentHookMetadata for registration mechanism + * + * @example + * ```typescript + * @ContextEstablishmentHook() + * export class BearerTokenHook implements IContextEstablishmentHook { + * hookDescription = { + * name: 'credentials.bearerToken' + * }; + * + * // ... hook implementation + * } + * + * ``` + */ +export type HookDescription = { + /** + * Unique identifier for this hook type. + * + * Used by the Hook Registry (to be implemented) to index and retrieve + * hook instances at runtime. Must be unique across all registered hooks. + * + * **Naming convention**: Use namespaced names like 'credentials.bearerToken' + * or 'envVars.tenantConfig' to organize hooks by domain and avoid collisions. + * + * **Usage contexts:** + * - Trigger node configuration specifies hooks by name + * - Hook Registry uses name as lookup key + * - UI displays localized names via i18n (e.g., `hooks.${name}.displayName`) + * - Logging and debugging references hooks by name + * - Error messages include hook name for troubleshooting + * + * **Versioning**: Future hook versions can use naming like 'credentials.bearerToken.v2' + * if breaking changes are needed, though this is not required initially. + * + * @example 'credentials.bearerToken' + * @example 'credentials.apiKey' + * @example 'envVars.tenantConfig' + * @example 'audit.requestMetadata' + */ + name: string; +}; + +/** + * Interface for context establishment hooks that extract data from trigger + * items and extend the execution context during workflow initialization. + * + * @see ContextEstablishmentOptions - Input parameters + * @see ContextEstablishmentResult - Output structure + * @see PlaintextExecutionContext - Runtime context type with decrypted data + */ +export interface IContextEstablishmentHook { + /** + * Self-describing metadata for this hook instance. + * + * Provides the unique name and future metadata used by the Hook Registry + * for discovery, lookup, and validation. This property makes each hook + * instance self-contained and discoverable without external configuration. + * + * @see HookDescription for detailed metadata structure and future extensions + * + * @example + * ```typescript + * @ContextEstablishmentHook() + * export class BearerTokenHook implements IContextEstablishmentHook { + * hookDescription = { + * name: 'credentials.bearerToken' + * }; + * + * async execute(options: ContextEstablishmentOptions) { + * // Hook implementation + * } + * + * isApplicableToTriggerNode(nodeType: string) { + * return nodeType === 'n8n-nodes-base.webhook'; + * } + * } + * ``` + */ + hookDescription: HookDescription; + /** + * Executes the hook to extract context data from trigger information. + * + * **Implementation requirements:** + * 1. Extract relevant data from trigger items (headers, body, query params, etc.) + * 2. Optionally modify trigger items to remove sensitive data + * 3. Return partial context updates to merge into execution context + * 4. Throw errors for unrecoverable failures (stops workflow execution) + * + * **Execution order:** + * Hooks execute sequentially in the order configured by the trigger node. + * Each hook receives: + * - Trigger items as modified by all previous hooks + * - Context with updates from all previous hooks + * + * **Context handling:** + * - Input context is plaintext (PlaintextExecutionContext) for runtime operations + * - Output updates are plaintext and will be encrypted before persistence + * - Never log or expose plaintext context outside hook execution + * + * **Error handling:** + * - Throw errors if required data is missing (e.g., expected header not found) + * - Use descriptive error messages for debugging + * - Errors stop workflow execution (fail-fast approach) + * + * @param options - Input parameters including trigger node, workflow, items, and current context + * @returns Promise resolving to modified trigger items and context updates + * @throws Error if hook execution fails (stops workflow execution) + * + * @example + * ```typescript + * async execute(options: ContextEstablishmentOptions): Promise { + * const removeHeader = options.options?.removeFromItem ?? true; + * + * // Extract data + * const token = this.extractToken(options.triggerItems); + * if (!token) { + * throw new Error('Bearer token not found in Authorization header'); + * } + * + * // Optionally modify items + * const modifiedItems = removeHeader + * ? this.removeAuthHeader(options.triggerItems) + * : undefined; + * + * // Return context update + * return { + * triggerItems: modifiedItems, + * contextUpdate: { + * credentials: { version: 1, identity: token } + * } + * }; + * } + * ``` + */ + execute(options: ContextEstablishmentOptions): Promise; + + /** + * Method to determine if this hook is applicable to a specific trigger node type. + * + * **Use cases:** + * - **UI filtering**: Show only relevant hooks for a trigger type in node configuration + * - **Validation**: Prevent incompatible hook configurations at save time + * - **Auto-suggestion**: Suggest applicable hooks based on trigger node selection + * - **Documentation**: Generate trigger-specific hook documentation + * + * **Implementation notes:** + * - Return true if the hook can extract meaningful data from this trigger type + * - Consider transport layer (HTTP, AMQP, manual, etc.) + * - Multiple triggers can share the same hook (e.g., webhook and form trigger both support bearer tokens) + * + * @param nodeType - The node type identifier (e.g., 'n8n-nodes-base.webhook') + * @returns true if this hook can be used with the given trigger node type + * + * @example + * ```typescript + * // Hook only works with HTTP-based triggers + * isApplicableToTriggerNode(nodeType: string): boolean { + * return [ + * 'n8n-nodes-base.webhook', + * 'n8n-nodes-base.formTrigger', + * 'n8n-nodes-base.httpRequest' + * ].includes(nodeType); + * } + * ``` + * + * @example + * ```typescript + * // Hook works with any trigger that has HTTP headers + * isApplicableToTriggerNode(nodeType: string): boolean { + * return nodeType.includes('webhook') || nodeType.includes('http'); + * } + * ``` + */ + isApplicableToTriggerNode(nodeType: string): boolean; +} + +/** + * Type representing the constructor/class of a context establishment hook. + * + * Used by the dependency injection container to register and instantiate + * hook classes at runtime. Works with the @ContextEstablishmentHook decorator. + * + * @see IContextEstablishmentHook + * @see ContextEstablishmentHook decorator in './index.ts' + * + * @example + * ```typescript + * import { Container } from '@n8n/di'; + * import type { ContextEstablishmentHookClass } from './context-establishment-hook'; + * + * const HookClass: ContextEstablishmentHookClass = BearerTokenHook; + * const hookInstance = Container.get(HookClass); + * ``` + */ +export type ContextEstablishmentHookClass = Constructable; diff --git a/packages/@n8n/decorators/src/context-establishment/index.ts b/packages/@n8n/decorators/src/context-establishment/index.ts new file mode 100644 index 00000000000..7ea4a597362 --- /dev/null +++ b/packages/@n8n/decorators/src/context-establishment/index.ts @@ -0,0 +1,5 @@ +export { + ContextEstablishmentHookMetadata, + ContextEstablishmentHook, +} from './context-establishment-hook-metadata'; +export type * from './context-establishment-hook'; diff --git a/packages/workflow/src/execution-context.ts b/packages/workflow/src/execution-context.ts index 14950ee8248..b91453bc18e 100644 --- a/packages/workflow/src/execution-context.ts +++ b/packages/workflow/src/execution-context.ts @@ -88,6 +88,55 @@ export const ExecutionContextSchema = z */ export type IExecutionContext = z.output; +/** + * Runtime representation of execution context with decrypted credential data. + * + * This type is identical to IExecutionContext except the `credentials` field + * contains the decrypted ICredentialContext object instead of an encrypted string. + * + * **Usage contexts:** + * - Hook execution: Hooks work with plaintext context to extract/merge credential data + * - Credential resolution: Resolvers need decrypted identity tokens + * - Internal processing: Runtime operations that need access to credential context + * + * **Security notes:** + * - Never persist this type to database - use IExecutionContext with encrypted credentials + * - Never expose in API responses or logs + * - Only exists in-memory during workflow execution + * - Should be cleared from memory after use + * + * **Lifecycle:** + * 1. Load IExecutionContext from storage (credentials encrypted) + * 2. Decrypt credentials field → PlaintextExecutionContext (runtime only) + * 3. Use for hook execution, credential resolution, etc. + * 4. Encrypt credentials → IExecutionContext before persistence + * + * @see IExecutionContext - Persisted form with encrypted credentials + * @see ICredentialContext - Decrypted credential structure + * @see IExecutionContextUpdate - Partial updates during hook execution + * + * @example + * ```typescript + * // During hook execution: + * const plaintextContext: PlaintextExecutionContext = { + * ...context, + * credentials: decryptCredentials(context.credentials) // Decrypt for runtime use + * }; + * + * // Hook can now access plaintext credential data + * const identity = plaintextContext.credentials?.identity; + * + * // Before storage, re-encrypt: + * const storableContext: IExecutionContext = { + * ...plaintextContext, + * credentials: encryptCredentials(plaintextContext.credentials) + * }; + * ``` + */ +export type PlaintextExecutionContext = Omit & { + credentials?: ICredentialContext; +}; + const safeParse = (value: string | object, schema: T) => { const typeName = schema.meta()?.title ?? 'Object'; try { From 3e03b30b77668a9f9c1a54057cf400ba9c6c7fb8 Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Thu, 20 Nov 2025 11:35:52 +0000 Subject: [PATCH 13/31] ci: Cleanup docker build scripts (#22032) --- .github/scripts/determine-runners-tags.sh | 119 ------ .github/scripts/docker/docker-config.mjs | 171 ++++++++ .github/scripts/docker/docker-tags.mjs | 113 ++++++ .github/workflows/docker-build-push.yml | 453 +++++----------------- 4 files changed, 374 insertions(+), 482 deletions(-) delete mode 100755 .github/scripts/determine-runners-tags.sh create mode 100644 .github/scripts/docker/docker-config.mjs create mode 100644 .github/scripts/docker/docker-tags.mjs diff --git a/.github/scripts/determine-runners-tags.sh b/.github/scripts/determine-runners-tags.sh deleted file mode 100755 index 5a15b3a52ad..00000000000 --- a/.github/scripts/determine-runners-tags.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Script to determine Docker tags for runners images (Alpine and distroless variants) -# -# Usage: determine-runners-tags.sh RELEASE_TYPE N8N_VERSION_TAG GHCR_BASE DOCKER_BASE PLATFORM GITHUB_OUTPUT -# -# Example: -# determine-runners-tags.sh \ -# "stable" \ -# "1.123.0" \ -# "ghcr.io/n8n-io/runners" \ -# "n8nio/runners" \ -# "amd64" \ -# "$GITHUB_OUTPUT" -# -# Output (written to GITHUB_OUTPUT): -# Alpine variant: -# tags=ghcr.io/n8n-io/runners:1.123.0-amd64, n8nio/runners:1.123.0-amd64 -# ghcr_platform_tag=ghcr.io/n8n-io/runners:1.123.0-amd64 -# dockerhub_platform_tag=n8nio/runners:1.123.0-amd64 -# primary_ghcr_manifest_tag=ghcr.io/n8n-io/runners:1.123.0 -# -# Distroless variant: -# tags_distroless=ghcr.io/n8n-io/runners:1.123.0-distroless-amd64, n8nio/runners:1.123.0-distroless-amd64 -# ghcr_platform_tag_distroless=ghcr.io/n8n-io/runners:1.123.0-distroless-amd64 -# dockerhub_platform_tag_distroless=n8nio/runners:1.123.0-distroless-amd64 -# primary_ghcr_manifest_tag_distroless=ghcr.io/n8n-io/runners:1.123.0-distroless - -RELEASE_TYPE="${1:?Missing RELEASE_TYPE argument}" -N8N_VERSION_TAG="${2:?Missing N8N_VERSION_TAG argument}" -GHCR_BASE="${3:?Missing GHCR_BASE argument}" -DOCKER_BASE="${4:?Missing DOCKER_BASE argument}" -PLATFORM="${5:?Missing PLATFORM argument}" -GITHUB_OUTPUT="${6:?Missing GITHUB_OUTPUT argument}" - -generate_tags() { - local VARIANT_SUFFIX="$1" - local OUTPUT_FILE="$2" - - local GHCR_TAGS_FOR_PUSH="" - local DOCKER_TAGS_FOR_PUSH="" - local PRIMARY_GHCR_MANIFEST_TAG_VALUE="" - - case "$RELEASE_TYPE" in - "stable") - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}-${PLATFORM}" - ;; - "nightly") - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:nightly${VARIANT_SUFFIX}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:nightly${VARIANT_SUFFIX}-${PLATFORM}" - ;; - "branch") - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="" - ;; - "dev"|*) - if [[ "$N8N_VERSION_TAG" == pr-* ]]; then - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="" - else - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:dev${VARIANT_SUFFIX}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:dev${VARIANT_SUFFIX}-${PLATFORM}" - fi - ;; - esac - - local ALL_TAGS="${GHCR_TAGS_FOR_PUSH}" - if [[ -n "$DOCKER_TAGS_FOR_PUSH" ]]; then - ALL_TAGS="${ALL_TAGS}\n${DOCKER_TAGS_FOR_PUSH}" - fi - - { - echo "tags<> "$OUTPUT_FILE" - - # Only output manifest tags from the first platform to avoid duplicates - if [[ "$PLATFORM" == "amd64" ]]; then - echo "primary_ghcr_manifest_tag=${PRIMARY_GHCR_MANIFEST_TAG_VALUE}" >> "$OUTPUT_FILE" - fi -} - -# Reads outputs from a temp file and appends them to GITHUB_OUTPUT with _distroless suffix -# Transforms variable names: tags -> tags_distroless, ghcr_platform_tag -> ghcr_platform_tag_distroless -transform_and_append_distroless_outputs() { - local TEMP_FILE="$1" - - while IFS= read -r line; do - if [[ "$line" == "tags<> "$GITHUB_OUTPUT" - elif [[ "$line" =~ ^(ghcr_platform_tag|dockerhub_platform_tag|primary_ghcr_manifest_tag)= ]]; then - key="${line%%=*}" - value="${line#*=}" - echo "${key}_distroless=${value}" >> "$GITHUB_OUTPUT" - else - # Pass through EOF markers and tag content - echo "$line" >> "$GITHUB_OUTPUT" - fi - done < "$TEMP_FILE" -} - -# Generate tags for Alpine variant (no suffix) -generate_tags "" "$GITHUB_OUTPUT" - -# Generate tags for distroless variant -DISTROLESS_OUTPUT=$(mktemp) -generate_tags "-distroless" "$DISTROLESS_OUTPUT" -transform_and_append_distroless_outputs "$DISTROLESS_OUTPUT" -rm "$DISTROLESS_OUTPUT" diff --git a/.github/scripts/docker/docker-config.mjs b/.github/scripts/docker/docker-config.mjs new file mode 100644 index 00000000000..5f426421bf4 --- /dev/null +++ b/.github/scripts/docker/docker-config.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node + +import { appendFileSync } from 'node:fs'; + +class BuildContext { + constructor() { + this.githubOutput = process.env.GITHUB_OUTPUT || null; + } + + determine({ event, pr, branch, version, releaseType, pushEnabled }) { + let context = { + version: '', + release_type: '', + platforms: ['linux/amd64', 'linux/arm64'], + push_to_ghcr: true, + push_to_docker: false, + }; + + // Determine version and release type based on event + switch (event) { + case 'schedule': + context.version = 'nightly'; + context.release_type = 'nightly'; + context.push_to_docker = true; + break; + + case 'pull_request': + context.version = `pr-${pr}`; + context.release_type = 'dev'; + context.push_to_ghcr = false; + break; + + case 'workflow_dispatch': + context.version = `branch-${this.sanitizeBranch(branch)}`; + context.release_type = 'branch'; + context.platforms = ['linux/amd64']; + break; + + case 'push': + if (branch === 'master') { + context.version = 'dev'; + context.release_type = 'dev'; + context.push_to_docker = true; + } else { + context.version = `branch-${this.sanitizeBranch(branch)}`; + context.release_type = 'branch'; + context.platforms = ['linux/amd64']; + } + break; + + case 'workflow_call': + case 'release': + if (!version) throw new Error('Version required for release'); + context.version = version; + context.release_type = releaseType || 'stable'; + context.push_to_docker = true; + break; + + default: + throw new Error(`Unknown event: ${event}`); + } + + // Handle push_enabled override + if (pushEnabled !== undefined) { + context.push_enabled = pushEnabled; + } else { + context.push_enabled = context.push_to_ghcr; + } + + return context; + } + + sanitizeBranch(branch) { + if (!branch) return 'unknown'; + return branch + .toLowerCase() + .replace(/[^a-z0-9._-]/g, '-') + .replace(/^[.-]/, '') + .replace(/[.-]$/, '') + .substring(0, 128); + } + + buildMatrix(platforms) { + const runners = { + 'linux/amd64': 'blacksmith-4vcpu-ubuntu-2204', + 'linux/arm64': 'blacksmith-4vcpu-ubuntu-2204-arm', + }; + + const matrix = { + platform: [], + include: [], + }; + + for (const platform of platforms) { + const shortName = platform.split('/').pop(); // amd64 or arm64 + matrix.platform.push(shortName); + matrix.include.push({ + platform: shortName, + runner: runners[platform], + docker_platform: platform, + }); + } + + return matrix; + } + + output(context, matrix = null) { + const buildMatrix = matrix || this.buildMatrix(context.platforms); + + if (this.githubOutput) { + const outputs = [ + `version=${context.version}`, + `release_type=${context.release_type}`, + `platforms=${JSON.stringify(context.platforms)}`, + `push_to_ghcr=${context.push_to_ghcr}`, + `push_to_docker=${context.push_to_docker}`, + `push_enabled=${context.push_enabled}`, + `build_matrix=${JSON.stringify(buildMatrix)}`, + ]; + appendFileSync(this.githubOutput, outputs.join('\n') + '\n'); + } else { + console.log(JSON.stringify({ ...context, build_matrix: buildMatrix }, null, 2)); + } + } +} + +// CLI - Simple argument parsing +if (import.meta.url === `file://${process.argv[1]}`) { + const args = process.argv.slice(2); + const getArg = (name) => { + const index = args.indexOf(`--${name}`); + if (index === -1 || !args[index + 1]) return undefined; + const value = args[index + 1]; + // Handle empty strings and 'null' as undefined + return value === '' || value === 'null' ? undefined : value; + }; + + try { + const context = new BuildContext(); + const pushEnabledArg = getArg('push-enabled'); + const result = context.determine({ + event: getArg('event') || process.env.GITHUB_EVENT_NAME, + pr: getArg('pr') || process.env.GITHUB_PR_NUMBER, + branch: getArg('branch') || process.env.GITHUB_REF_NAME, + version: getArg('version'), + releaseType: getArg('release-type'), + pushEnabled: pushEnabledArg === 'true' ? true : pushEnabledArg === 'false' ? false : undefined, + }); + + const matrix = context.buildMatrix(result.platforms); + + // Debug output when GITHUB_OUTPUT is set (running in Actions) + if (context.githubOutput) { + console.log('=== Build Context ==='); + console.log(`version: ${result.version}`); + console.log(`release_type: ${result.release_type}`); + console.log(`platforms: ${JSON.stringify(result.platforms, null, 2)}`); + console.log(`push_to_ghcr: ${result.push_to_ghcr}`); + console.log(`push_to_docker: ${result.push_to_docker}`); + console.log(`push_enabled: ${result.push_enabled}`); + console.log('build_matrix:', JSON.stringify(matrix, null, 2)); + } + + context.output(result, matrix); + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +export default BuildContext; \ No newline at end of file diff --git a/.github/scripts/docker/docker-tags.mjs b/.github/scripts/docker/docker-tags.mjs new file mode 100644 index 00000000000..8f1f7dac631 --- /dev/null +++ b/.github/scripts/docker/docker-tags.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +import { appendFileSync } from 'node:fs'; + +class TagGenerator { + constructor() { + this.githubOwner = process.env.GITHUB_REPOSITORY_OWNER || 'n8n-io'; + this.dockerUsername = process.env.DOCKER_USERNAME || 'n8nio'; + this.githubOutput = process.env.GITHUB_OUTPUT || null; + } + + generate({ image, version, platform, includeDockerHub = false }) { + let imageName = image; + let versionSuffix = ''; + + if (image === 'runners-distroless') { + imageName = 'runners'; + versionSuffix = '-distroless'; + } + + const platformSuffix = platform ? `-${platform.split('/').pop()}` : ''; + const fullVersion = `${version}${versionSuffix}${platformSuffix}`; + + const tags = { + ghcr: [`ghcr.io/${this.githubOwner}/${imageName}:${fullVersion}`], + docker: includeDockerHub ? [`${this.dockerUsername}/${imageName}:${fullVersion}`] : [], + }; + + tags.all = [...tags.ghcr, ...tags.docker]; + return tags; + } + + output(tags, prefix = '') { + if (this.githubOutput) { + const prefixStr = prefix ? `${prefix}_` : ''; + const primaryTag = tags.ghcr[0] ? tags.ghcr[0].replace(/-amd64$|-arm64$/, '') : ''; + const outputs = [ + `${prefixStr}tags=${tags.all.join(',')}`, + `${prefixStr}ghcr_tag=${tags.ghcr[0] || ''}`, + `${prefixStr}docker_tag=${tags.docker[0] || ''}`, + `${prefixStr}primary_tag=${primaryTag}`, + ]; + appendFileSync(this.githubOutput, outputs.join('\n') + '\n'); + } else { + console.log(JSON.stringify(tags, null, 2)); + } + } + + generateAll({ version, platform, includeDockerHub = false }) { + const images = ['n8n', 'runners', 'runners-distroless']; + const results = {}; + + for (const image of images) { + const tags = this.generate({ image, version, platform, includeDockerHub }); + const prefix = image.replace('-distroless', '_distroless'); + results[prefix] = tags; + + if (this.githubOutput) { + this.output(tags, prefix); + } + } + + return results; + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = process.argv.slice(2); + const getArg = (name) => { + const index = args.indexOf(`--${name}`); + return index !== -1 && args[index + 1] ? args[index + 1] : undefined; + }; + const hasFlag = (name) => args.includes(`--${name}`); + + try { + const generator = new TagGenerator(); + const version = getArg('version'); + + if (!version) { + console.error('Error: --version is required'); + process.exit(1); + } + + if (hasFlag('all')) { + const results = generator.generateAll({ + version, + platform: getArg('platform'), + includeDockerHub: hasFlag('include-docker'), + }); + if (!generator.githubOutput) { + console.log(JSON.stringify(results, null, 2)); + } + } else { + const image = getArg('image'); + if (!image) { + console.error('Error: Either --image or --all is required'); + process.exit(1); + } + const tags = generator.generate({ + image, + version, + platform: getArg('platform'), + includeDockerHub: hasFlag('include-docker'), + }); + generator.output(tags); + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +export default TagGenerator; diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 6695b1ff5ba..9ef2690077f 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -1,9 +1,7 @@ # This workflow is used to build and push the Docker image for n8nio/n8n and n8nio/runners -# - determine-build-context: Determines what needs to be built based on the trigger -# - build-and-push-docker: This builds on both an ARM64 and AMD64 runner so the builds are native to the platform. Uses blacksmith native runners and build-push-action -# - create_multi_arch_manifest: This creates the multi-arch manifest for the Docker image. Needed to recombine the images from the build-and-push-docker job since they are separate runners. -# - security-scan: This scans the n8nio/n8n Docker image for security vulnerabilities using Trivy. -# - security-scan-runners: This scans the n8nio/runners Docker image for security vulnerabilities using Trivy. +# +# - Uses docker-config.mjs for context determination, this determines what needs to be built based on the trigger +# - Uses docker-tags.mjs for tag generation, this generates the tags for the images name: 'Docker: Build and Push' @@ -50,6 +48,8 @@ on: - ready_for_review paths: - '.github/workflows/docker-build-push.yml' + - '.github/scripts/docker/docker-config.mjs' + - '.github/scripts/docker/docker-tags.mjs' - 'docker/images/n8n/Dockerfile' - 'docker/images/runners/Dockerfile' - 'docker/images/runners/Dockerfile.distroless' @@ -60,107 +60,24 @@ jobs: runs-on: ubuntu-latest outputs: release_type: ${{ steps.context.outputs.release_type }} - n8n_version: ${{ steps.context.outputs.n8n_version }} + n8n_version: ${{ steps.context.outputs.version }} push_enabled: ${{ steps.context.outputs.push_enabled }} - build_matrix: ${{ steps.matrix.outputs.matrix }} + push_to_docker: ${{ steps.context.outputs.push_to_docker }} + build_matrix: ${{ steps.context.outputs.build_matrix }} steps: - - name: Determine build context values + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Determine build context id: context run: | - # Debug info - echo "Event: ${{ github.event_name }}" - echo "Ref: ${{ github.ref }}" - echo "Ref Name: ${{ github.ref_name }}" - - # Check if called by another workflow (has n8n_version input) - if [[ -n "${{ inputs.n8n_version }}" ]]; then - # workflow_call - used for releases - { - echo "release_type=${{ inputs.release_type }}" - echo "n8n_version=${{ inputs.n8n_version }}" - echo "push_enabled=${{ inputs.push_enabled }}" - } >> "$GITHUB_OUTPUT" - - elif [[ "${{ github.event_name }}" == "schedule" ]]; then - # Nightly builds - { - echo "release_type=nightly" - echo "n8n_version=snapshot" - echo "push_enabled=true" - } >> "$GITHUB_OUTPUT" - - elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - # Build branches for Nathan deploy - BRANCH_NAME="${{ github.ref_name }}" - - # Fallback to parsing ref if ref_name is empty - if [[ -z "$BRANCH_NAME" ]] && [[ "${{ github.ref }}" =~ ^refs/heads/(.+)$ ]]; then - BRANCH_NAME="${BASH_REMATCH[1]}" - fi - - # Sanitize branch name for Docker tag - SAFE_BRANCH_NAME=$(echo "$BRANCH_NAME" | tr '/' '-' | tr -cd '[:alnum:]-_') - - if [[ -z "$SAFE_BRANCH_NAME" ]]; then - echo "Error: Could not determine valid branch name" - exit 1 - fi - - { - echo "release_type=branch" - echo "n8n_version=branch-${SAFE_BRANCH_NAME}" - echo "push_enabled=${{ inputs.push_enabled }}" - } >> "$GITHUB_OUTPUT" - - elif [[ "${{ github.event_name }}" == "pull_request" ]]; then - # Direct PR triggers for testing Dockerfile changes - { - echo "release_type=dev" - echo "n8n_version=pr-${{ github.event.pull_request.number }}" - echo "push_enabled=false" - } >> "$GITHUB_OUTPUT" - fi - - # Output summary for logs - echo "=== Build Context Summary ===" - echo "Release type: $(grep release_type "$GITHUB_OUTPUT" | cut -d= -f2)" - echo "N8N version: $(grep n8n_version "$GITHUB_OUTPUT" | cut -d= -f2)" - echo "Push enabled: $(grep push_enabled "$GITHUB_OUTPUT" | cut -d= -f2)" - - - name: Determine build matrix - id: matrix - run: | - RELEASE_TYPE="${{ steps.context.outputs.release_type }}" - - # Branch builds only need AMD64, everything else needs both platforms - if [[ "$RELEASE_TYPE" == "branch" ]]; then - MATRIX='{ - "platform": ["amd64"], - "include": [{ - "platform": "amd64", - "runner": "blacksmith-4vcpu-ubuntu-2204", - "docker_platform": "linux/amd64" - }] - }' - else - # All other builds (stable, nightly, dev, PR) need both platforms - MATRIX='{ - "platform": ["amd64", "arm64"], - "include": [{ - "platform": "amd64", - "runner": "blacksmith-4vcpu-ubuntu-2204", - "docker_platform": "linux/amd64" - }, { - "platform": "arm64", - "runner": "blacksmith-4vcpu-ubuntu-2204-arm", - "docker_platform": "linux/arm64" - }] - }' - fi - - # Output matrix as single line for GITHUB_OUTPUT - echo "matrix=$(echo "$MATRIX" | jq -c .)" >> "$GITHUB_OUTPUT" - echo "Build matrix: $(echo "$MATRIX" | jq .)" + node .github/scripts/docker/docker-config.mjs \ + --event "${{ github.event_name }}" \ + --pr "${{ github.event.pull_request.number }}" \ + --branch "${{ github.ref_name }}" \ + --version "${{ inputs.n8n_version }}" \ + --release-type "${{ inputs.release_type }}" \ + --push-enabled "${{ inputs.push_enabled }}" build-and-push-docker: name: Build App, then Build and Push Docker Image (${{ matrix.platform }}) @@ -170,10 +87,10 @@ jobs: strategy: matrix: ${{ fromJSON(needs.determine-build-context.outputs.build_matrix) }} outputs: - image_ref: ${{ steps.determine-tags.outputs.primary_ghcr_manifest_tag }} - primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.primary_ghcr_manifest_tag }} - runners_primary_ghcr_manifest_tag: ${{ steps.determine-runners-tags.outputs.primary_ghcr_manifest_tag }} - runners_distroless_primary_ghcr_manifest_tag: ${{ steps.determine-runners-tags.outputs.primary_ghcr_manifest_tag_distroless }} + image_ref: ${{ steps.determine-tags.outputs.n8n_primary_tag }} + primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.n8n_primary_tag }} + runners_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_primary_tag }} + runners_distroless_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_distroless_primary_tag }} steps: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -181,101 +98,23 @@ jobs: fetch-depth: 0 - name: Setup and Build - uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1 + uses: ./.github/actions/setup-nodejs-blacksmith with: build-command: pnpm build:n8n - - name: Determine Docker tags + - name: Determine Docker tags for all images id: determine-tags run: | - RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}" - N8N_VERSION_TAG="${{ needs.determine-build-context.outputs.n8n_version }}" - GHCR_BASE="ghcr.io/${{ github.repository_owner }}/n8n" - DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/n8n" - PLATFORM="${{ matrix.platform }}" + node .github/scripts/docker/docker-tags.mjs \ + --all \ + --version "${{ needs.determine-build-context.outputs.n8n_version }}" \ + --platform "${{ matrix.docker_platform }}" \ + ${{ needs.determine-build-context.outputs.push_to_docker == 'true' && '--include-docker' || '' }} - GHCR_TAGS_FOR_PUSH="" - DOCKER_TAGS_FOR_PUSH="" - - PRIMARY_GHCR_MANIFEST_TAG_VALUE="" - - # Validate inputs - if [[ "$RELEASE_TYPE" == "stable" && -z "$N8N_VERSION_TAG" ]]; then - echo "Error: N8N_VERSION_TAG is empty for a stable release." - exit 1 - fi - - if [[ "$RELEASE_TYPE" == "branch" && -z "$N8N_VERSION_TAG" ]]; then - echo "Error: N8N_VERSION_TAG is empty for a branch release." - exit 1 - fi - - # Determine tags based on release type - case "$RELEASE_TYPE" in - "stable") - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:${N8N_VERSION_TAG}-${PLATFORM}" - ;; - "nightly") - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:nightly" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:nightly-${PLATFORM}" - ;; - "branch") - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - # No Docker Hub tags for branch builds - DOCKER_TAGS_FOR_PUSH="" - ;; - "dev"|*) - if [[ "$N8N_VERSION_TAG" == pr-* ]]; then - # PR builds only go to GHCR - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="" - else - # Regular dev builds go to both registries - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:dev" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:dev-${PLATFORM}" - fi - ;; - esac - - # Combine all tags - ALL_TAGS="${GHCR_TAGS_FOR_PUSH}" - if [[ -n "$DOCKER_TAGS_FOR_PUSH" ]]; then - ALL_TAGS="${ALL_TAGS}\n${DOCKER_TAGS_FOR_PUSH}" - fi - - echo "Generated Tags for push: $ALL_TAGS" - { - echo "tags<> "$GITHUB_OUTPUT" - - { - echo "ghcr_platform_tag=${GHCR_TAGS_FOR_PUSH}" - echo "dockerhub_platform_tag=${DOCKER_TAGS_FOR_PUSH}" - } >> "$GITHUB_OUTPUT" - - # Only output manifest tags from the first platform to avoid duplicates - if [[ "$PLATFORM" == "amd64" ]]; then - echo "primary_ghcr_manifest_tag=${PRIMARY_GHCR_MANIFEST_TAG_VALUE}" >> "$GITHUB_OUTPUT" - fi - - - name: Determine Docker tags (runners variants) - id: determine-runners-tags - run: | - .github/scripts/determine-runners-tags.sh \ - "${{ needs.determine-build-context.outputs.release_type }}" \ - "${{ needs.determine-build-context.outputs.n8n_version }}" \ - "ghcr.io/${{ github.repository_owner }}/runners" \ - "${{ secrets.DOCKER_USERNAME }}/runners" \ - "${{ matrix.platform }}" \ - "$GITHUB_OUTPUT" + echo "=== Generated Docker Tags ===" + cat "$GITHUB_OUTPUT" | grep "_tags=" | while IFS='=' read -r key value; do + echo "${key}: ${value%%,*}..." # Show first tag for brevity + done - name: Set up Docker Buildx uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 @@ -289,10 +128,9 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - if: needs.determine-build-context.outputs.push_enabled == 'true' && ( - steps.determine-tags.outputs.dockerhub_platform_tag != '' || - steps.determine-runners-tags.outputs.dockerhub_platform_tag != '' || - steps.determine-runners-tags.outputs.dockerhub_platform_tag_distroless != '') + if: | + needs.determine-build-context.outputs.push_enabled == 'true' && + needs.determine-build-context.outputs.push_to_docker == 'true' uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} @@ -311,7 +149,7 @@ jobs: provenance: true sbom: true push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }} - tags: ${{ steps.determine-tags.outputs.tags }} + tags: ${{ steps.determine-tags.outputs.n8n_tags }} - name: Build and push task runners Docker image (Alpine) uses: useblacksmith/build-push-action@574eb0ee0b59c6a687ace24192f0727dfb65d6d7 # v1.2 @@ -326,7 +164,7 @@ jobs: provenance: true sbom: true push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }} - tags: ${{ steps.determine-runners-tags.outputs.tags }} + tags: ${{ steps.determine-tags.outputs.runners_tags }} - name: Build and push task runners Docker image (distroless) uses: useblacksmith/build-push-action@574eb0ee0b59c6a687ace24192f0727dfb65d6d7 # v1.2 @@ -341,7 +179,7 @@ jobs: provenance: true sbom: true push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }} - tags: ${{ steps.determine-runners-tags.outputs.tags_distroless }} + tags: ${{ steps.determine-tags.outputs.runners_distroless_tags }} create_multi_arch_manifest: name: Create Multi-Arch Manifest @@ -361,181 +199,70 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Determine Docker Hub manifest tag - id: dockerhub_check - run: | - RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}" - N8N_VERSION="${{ needs.determine-build-context.outputs.n8n_version }}" - DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/n8n" - - # Determine if Docker Hub manifest is needed and construct the tag - case "$RELEASE_TYPE" in - "stable") - { - echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:${N8N_VERSION}" - echo "CREATE_DOCKERHUB_MANIFEST=true" - } >> "$GITHUB_OUTPUT" - ;; - "nightly") - { - echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:nightly" - echo "CREATE_DOCKERHUB_MANIFEST=true" - } >> "$GITHUB_OUTPUT" - ;; - "dev") - if [[ "$N8N_VERSION" != pr-* ]]; then - { - echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:dev" - echo "CREATE_DOCKERHUB_MANIFEST=true" - } >> "$GITHUB_OUTPUT" - else - echo "CREATE_DOCKERHUB_MANIFEST=false" >> "$GITHUB_OUTPUT" - fi - ;; - *) - echo "CREATE_DOCKERHUB_MANIFEST=false" >> "$GITHUB_OUTPUT" - ;; - esac - - - name: Determine Docker Hub manifest tags (runners variants) - id: dockerhub_runners_check - run: | - RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}" - N8N_VERSION="${{ needs.determine-build-context.outputs.n8n_version }}" - DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/runners" - - # Generate manifest tags for both Alpine and distroless variants - for VARIANT in "" "_distroless"; do - SUFFIX="${VARIANT//_/-}" # Convert _distroless to -distroless for tag - OUTPUT_SUFFIX="${VARIANT}" # Keep underscore for output var names - - case "$RELEASE_TYPE" in - "stable") - echo "DOCKER_MANIFEST_TAG${OUTPUT_SUFFIX}=${DOCKER_BASE}:${N8N_VERSION}${SUFFIX}" >> "$GITHUB_OUTPUT" - echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=true" >> "$GITHUB_OUTPUT" - ;; - "nightly") - echo "DOCKER_MANIFEST_TAG${OUTPUT_SUFFIX}=${DOCKER_BASE}:nightly${SUFFIX}" >> "$GITHUB_OUTPUT" - echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=true" >> "$GITHUB_OUTPUT" - ;; - "dev") - if [[ "$N8N_VERSION" != pr-* ]]; then - echo "DOCKER_MANIFEST_TAG${OUTPUT_SUFFIX}=${DOCKER_BASE}:dev${SUFFIX}" >> "$GITHUB_OUTPUT" - echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=true" >> "$GITHUB_OUTPUT" - else - echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=false" >> "$GITHUB_OUTPUT" - fi - ;; - *) - echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=false" >> "$GITHUB_OUTPUT" - ;; - esac - done - - name: Login to Docker Hub - if: steps.dockerhub_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' || - steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' || - steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST_distroless == 'true' + if: needs.determine-build-context.outputs.push_to_docker == 'true' uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Create GHCR multi-arch manifest - if: needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag != '' + - name: Create GHCR multi-arch manifests run: | - MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}" RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}" - echo "Creating GHCR manifest: $MANIFEST_TAG" + # Function to create manifest for an image + create_manifest() { + local IMAGE_NAME=$1 + local MANIFEST_TAG=$2 - # For branch builds, only AMD64 is built - if [[ "$RELEASE_TYPE" == "branch" ]]; then - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 - else - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 \ - ${MANIFEST_TAG}-arm64 - fi + if [[ -z "$MANIFEST_TAG" ]]; then + echo "Skipping $IMAGE_NAME - no manifest tag" + return + fi - - name: Create GHCR multi-arch manifest for runners (Alpine) - if: needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag != '' + echo "Creating GHCR manifest for $IMAGE_NAME: $MANIFEST_TAG" + + # For branch builds, only AMD64 is built + if [[ "$RELEASE_TYPE" == "branch" ]]; then + docker buildx imagetools create \ + --tag "$MANIFEST_TAG" \ + "${MANIFEST_TAG}-amd64" + else + docker buildx imagetools create \ + --tag "$MANIFEST_TAG" \ + "${MANIFEST_TAG}-amd64" \ + "${MANIFEST_TAG}-arm64" + fi + } + + # Create manifests for all images + create_manifest "n8n" "${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}" + create_manifest "runners" "${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}" + create_manifest "runners-distroless" "${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}" + + - name: Create Docker Hub manifests + if: needs.determine-build-context.outputs.push_to_docker == 'true' run: | - MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}" - RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}" + VERSION="${{ needs.determine-build-context.outputs.n8n_version }}" + DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}" - echo "Creating GHCR runners manifest: $MANIFEST_TAG" + # Create manifests for each image type + declare -A images=( + ["n8n"]="${VERSION}" + ["runners"]="${VERSION}" + ["runners-distroless"]="${VERSION}-distroless" + ) - # For branch builds, only AMD64 is built - if [[ "$RELEASE_TYPE" == "branch" ]]; then + for image in "${!images[@]}"; do + TAG_SUFFIX="${images[$image]}" + IMAGE_NAME="${image//-distroless/}" # Remove -distroless from image name + + echo "Creating Docker Hub manifest for $image" docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 - else - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 \ - ${MANIFEST_TAG}-arm64 - fi - - - name: Create GHCR multi-arch manifest for runners (distroless) - if: needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag != '' - run: | - MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}" - RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}" - - echo "Creating GHCR runners distroless manifest: $MANIFEST_TAG" - - # For branch builds, only AMD64 is built - if [[ "$RELEASE_TYPE" == "branch" ]]; then - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 - else - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 \ - ${MANIFEST_TAG}-arm64 - fi - - - name: Create Docker Hub multi-arch manifest - if: steps.dockerhub_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' - run: | - MANIFEST_TAG="${{ steps.dockerhub_check.outputs.DOCKER_MANIFEST_TAG }}" - - echo "Creating Docker Hub manifest: $MANIFEST_TAG" - - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 \ - ${MANIFEST_TAG}-arm64 - - - name: Create Docker Hub multi-arch manifest for runners (Alpine) - if: steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' - run: | - MANIFEST_TAG="${{ steps.dockerhub_runners_check.outputs.DOCKER_MANIFEST_TAG }}" - - echo "Creating Docker Hub manifest: $MANIFEST_TAG" - - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 \ - ${MANIFEST_TAG}-arm64 - - - name: Create Docker Hub multi-arch manifest for runners (distroless) - if: steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST_distroless == 'true' - run: | - MANIFEST_TAG="${{ steps.dockerhub_runners_check.outputs.DOCKER_MANIFEST_TAG_distroless }}" - - echo "Creating Docker Hub distroless manifest: $MANIFEST_TAG" - - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 \ - ${MANIFEST_TAG}-arm64 + --tag "${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}" \ + "${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-amd64" \ + "${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-arm64" + done call-success-url: name: Call Success URL @@ -566,7 +293,7 @@ jobs: security-scan-runners: name: Security Scan (runners) - needs: [determine-build-context, build-and-push-docker] + needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest] if: | success() && (needs.determine-build-context.outputs.release_type == 'stable' || From a0c2071cb0ba324bd2d42225f4e96b4a3a0b1361 Mon Sep 17 00:00:00 2001 From: Nikhil Kuriakose Date: Thu, 20 Nov 2025 12:36:52 +0100 Subject: [PATCH 14/31] fix(API): Add correct payload example (#22057) --- .../credentials/spec/schemas/create-credential-response.yml | 2 +- .../v1/handlers/credentials/spec/schemas/credential.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/create-credential-response.yml b/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/create-credential-response.yml index fa906cfd293..04cfedc60b7 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/create-credential-response.yml +++ b/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/create-credential-response.yml @@ -15,7 +15,7 @@ properties: example: John's Github account type: type: string - example: github + example: githubApi createdAt: type: string format: date-time diff --git a/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/credential.yml b/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/credential.yml index 08c208f59a2..b131d112f89 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/credential.yml +++ b/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/credential.yml @@ -13,11 +13,11 @@ properties: example: Joe's Github Credentials type: type: string - example: github + example: githubApi data: type: object writeOnly: true - example: { token: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' } + example: { accessToken: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' } createdAt: type: string format: date-time From e569750e328d62fd7a3fbd41f9fcb7c7320da23b Mon Sep 17 00:00:00 2001 From: Nikhil Kuriakose Date: Thu, 20 Nov 2025 12:39:08 +0100 Subject: [PATCH 15/31] fix(editor): Replace icon for null in schema view (#21415) --- .../frontend/@n8n/design-system/src/components/N8nIcon/icons.ts | 2 ++ .../frontend/editor-ui/src/app/composables/useDataSchema.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 37e20f93b97..e49dd281e25 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -188,6 +188,7 @@ import IconLucideSmile from '~icons/lucide/smile'; import IconLucideSparkles from '~icons/lucide/sparkles'; import IconLucideSquare from '~icons/lucide/square'; import IconLucideSquareCheck from '~icons/lucide/square-check'; +import IconLucideSquareMinus from '~icons/lucide/square-minus'; import IconLucideSquarePen from '~icons/lucide/square-pen'; import IconLucideSquarePlus from '~icons/lucide/square-plus'; import IconLucideStickyNote from '~icons/lucide/sticky-note'; @@ -626,6 +627,7 @@ export const updatedIconSet = { sparkles: IconLucideSparkles, square: IconLucideSquare, 'square-check': IconLucideSquareCheck, + 'square-minus': IconLucideSquareMinus, 'square-pen': IconLucideSquarePen, 'square-plus': IconLucideSquarePlus, 'sticky-note': IconLucideStickyNote, diff --git a/packages/frontend/editor-ui/src/app/composables/useDataSchema.ts b/packages/frontend/editor-ui/src/app/composables/useDataSchema.ts index 5489078c0c6..7446a26b8da 100644 --- a/packages/frontend/editor-ui/src/app/composables/useDataSchema.ts +++ b/packages/frontend/editor-ui/src/app/composables/useDataSchema.ts @@ -319,7 +319,7 @@ const icons = { object: DATA_TYPE_ICON_MAP.object, array: DATA_TYPE_ICON_MAP.array, ['string']: DATA_TYPE_ICON_MAP.string, - null: 'case-upper', + null: 'square-minus', ['number']: DATA_TYPE_ICON_MAP.number, ['boolean']: DATA_TYPE_ICON_MAP.boolean, function: 'code', From 3d3e8ccf1d16f7a965192b369811323d97f88fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 20 Nov 2025 12:48:16 +0100 Subject: [PATCH 16/31] fix(core)!: Switch to image extension for `n8nio/runners` (#22079) --- docker/images/runners/Dockerfile | 14 ---------- docker/images/runners/Dockerfile.distroless | 31 ++++++++++++--------- docker/images/runners/extras.txt | 5 ---- docker/images/runners/package.json | 8 ------ packages/cli/BREAKING-CHANGES.md | 10 +++++++ 5 files changed, 28 insertions(+), 40 deletions(-) delete mode 100644 docker/images/runners/extras.txt delete mode 100644 docker/images/runners/package.json diff --git a/docker/images/runners/Dockerfile b/docker/images/runners/Dockerfile index 9d5c59e3304..f525203aade 100644 --- a/docker/images/runners/Dockerfile +++ b/docker/images/runners/Dockerfile @@ -11,15 +11,6 @@ WORKDIR /app/task-runner-javascript RUN corepack enable pnpm -# Install extra runtime-only npm packages. Allow usage in the Code node via -# 'NODE_FUNCTION_ALLOW_EXTERNAL' env variable on n8n-task-runners.json. -RUN rm -f node_modules/.modules.yaml -RUN mv package.json package.json.bak -COPY docker/images/runners/package.json /app/task-runner-javascript/package.json -RUN pnpm install --prod --no-lockfile --silent -RUN mv package.json extras.json -RUN mv package.json.bak package.json - # Remove `catalog` and `workspace` references from package.json to allow `pnpm add` in extended images RUN node -e "const pkg = require('./package.json'); \ Object.keys(pkg.dependencies || {}).forEach(k => { \ @@ -79,11 +70,6 @@ RUN uv sync \ --all-extras \ --no-editable -# Install extra runtime-only Python packages. Allow usage in the Code node via -# 'N8N_RUNNERS_EXTERNAL_ALLOW' env variable on n8n-task-runners.json. -COPY docker/images/runners/extras.txt /app/task-runner-python/extras.txt -RUN uv pip install -r /app/task-runner-python/extras.txt - # ============================================================================== # STAGE 3: Task Runner Launcher download # ============================================================================== diff --git a/docker/images/runners/Dockerfile.distroless b/docker/images/runners/Dockerfile.distroless index 0e606be1eef..39fbfcf3ed1 100644 --- a/docker/images/runners/Dockerfile.distroless +++ b/docker/images/runners/Dockerfile.distroless @@ -26,14 +26,24 @@ WORKDIR /app/task-runner-javascript RUN corepack enable pnpm -# Install extra runtime-only npm packages. Allow usage in the Code node via -# 'NODE_FUNCTION_ALLOW_EXTERNAL' env variable on n8n-task-runners.json. -RUN rm -f node_modules/.modules.yaml -RUN mv package.json package.json.bak -COPY docker/images/runners/package.json /app/task-runner-javascript/package.json -RUN pnpm install --prod --no-lockfile --silent -RUN mv package.json extras.json -RUN mv package.json.bak package.json +# Remove `catalog` and `workspace` references from package.json to allow `pnpm add` +RUN node -e "const pkg = require('./package.json'); \ + Object.keys(pkg.dependencies || {}).forEach(k => { \ + const val = pkg.dependencies[k]; \ + if (val === 'catalog:' || val.startsWith('catalog:') || val.startsWith('workspace:')) \ + delete pkg.dependencies[k]; \ + }); \ + Object.keys(pkg.devDependencies || {}).forEach(k => { \ + const val = pkg.devDependencies[k]; \ + if (val === 'catalog:' || val.startsWith('catalog:') || val.startsWith('workspace:')) \ + delete pkg.devDependencies[k]; \ + }); \ + delete pkg.devDependencies; \ + require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));" + +# Install moment by default (special case for n8n cloud) +RUN rm -f node_modules/.modules.yaml && \ + pnpm add moment@2.30.1 --prod --no-lockfile # ============================================================================== # STAGE 2: Python runner build (@n8n/task-runner-python) with uv @@ -81,11 +91,6 @@ RUN uv sync \ --all-extras \ --no-editable -# Install extra runtime-only Python packages. Allow usage in the Code node via -# 'N8N_RUNNERS_EXTERNAL_ALLOW' env variable on n8n-task-runners.json. -COPY docker/images/runners/extras.txt /app/task-runner-python/extras.txt -RUN uv pip install -r /app/task-runner-python/extras.txt - # ============================================================================== # STAGE 3: Task Runner Launcher download # ============================================================================== diff --git a/docker/images/runners/extras.txt b/docker/images/runners/extras.txt deleted file mode 100644 index a1d51a235fa..00000000000 --- a/docker/images/runners/extras.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Runtime-only extra Requirements File for installing dependencies in the Python task runner image. -# Installed at Docker image build time. Allow usage in the Code node -# via 'N8N_RUNNERS_EXTERNAL_ALLOW' env variable on n8n-task-runners.json. - -# numpy==2.3.2 diff --git a/docker/images/runners/package.json b/docker/images/runners/package.json deleted file mode 100644 index 53afeb3f90c..00000000000 --- a/docker/images/runners/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "task-runner-runtime-extras", - "description": "Runtime-only extra dependencies for installing packages in the JavaScript task runner image. Installed at Docker image build time. Allow usage in the Code node via 'NODE_FUNCTION_ALLOW_EXTERNAL' env variable on n8n-task-runners.json.", - "private": true, - "dependencies": { - "moment": "2.30.1" - } -} diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index eb72ee82c2e..e7550290b37 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,6 +2,16 @@ This list shows all the versions which include breaking changes and how to upgrade. +# 1.122.0 + +### What changed? + +The way to add third-party dependencies to the `n8nio/runners` image has changed. More details [here](https://docs.n8n.io/hosting/configuration/task-runners/#adding-extra-dependencies). + +### When is action necessary? + +If you adding third-party dependencies to the `n8nio/runners` image using `package.json` and `extras.txt` and building the image yourself, please extend the image as instructed in the link above. + # 1.113.0 ### What changed? From bf1511ae5789642f1db524585e3eae80cfac7029 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Thu, 20 Nov 2025 14:09:07 +0200 Subject: [PATCH 17/31] chore: Remove self-install telemetry (#21988) --- packages/frontend/editor-ui/src/app/App.vue | 5 +- .../src/app/components/Telemetry.test.ts | 127 ---------------- .../src/app/components/Telemetry.vue | 73 --------- .../useTelemetryInitializer.test.ts | 140 ++++++++++++++++++ .../composables/useTelemetryInitializer.ts | 71 +++++++++ .../tests/ui/46-n8n-io-iframe.spec.ts | 71 --------- 6 files changed, 214 insertions(+), 273 deletions(-) delete mode 100644 packages/frontend/editor-ui/src/app/components/Telemetry.test.ts delete mode 100644 packages/frontend/editor-ui/src/app/components/Telemetry.vue create mode 100644 packages/frontend/editor-ui/src/app/composables/useTelemetryInitializer.test.ts create mode 100644 packages/frontend/editor-ui/src/app/composables/useTelemetryInitializer.ts delete mode 100644 packages/testing/playwright/tests/ui/46-n8n-io-iframe.spec.ts diff --git a/packages/frontend/editor-ui/src/app/App.vue b/packages/frontend/editor-ui/src/app/App.vue index 02ae96b0619..3a421fe0b5a 100644 --- a/packages/frontend/editor-ui/src/app/App.vue +++ b/packages/frontend/editor-ui/src/app/App.vue @@ -3,9 +3,9 @@ import AssistantsHub from '@/features/ai/assistant/components/AssistantsHub.vue' import AskAssistantFloatingButton from '@/features/ai/assistant/components/Chat/AskAssistantFloatingButton.vue'; import BannerStack from '@/features/shared/banners/components/BannerStack.vue'; import Modals from '@/app/components/Modals.vue'; -import Telemetry from '@/app/components/Telemetry.vue'; import { useHistoryHelper } from '@/app/composables/useHistoryHelper'; import { useTelemetryContext } from '@/app/composables/useTelemetryContext'; +import { useTelemetryInitializer } from '@/app/composables/useTelemetryInitializer'; import { useWorkflowDiffRouting } from '@/app/composables/useWorkflowDiffRouting'; import { APP_MODALS_ELEMENT_ID, @@ -63,6 +63,8 @@ useHistoryHelper(route); // Initialize workflow diff routing management useWorkflowDiffRouting(); +useTelemetryInitializer(); + const loading = ref(true); const defaultLocale = computed(() => rootStore.defaultLocale); const isDemoMode = computed(() => route.name === VIEWS.DEMO); @@ -181,7 +183,6 @@ useExposeCssVar('--ask-assistant--floating-button--margin-bottom', askAiFloating @input-change="onCommandBarChange" @navigate-to="onCommandBarNavigateTo" /> - diff --git a/packages/frontend/editor-ui/src/app/components/Telemetry.test.ts b/packages/frontend/editor-ui/src/app/components/Telemetry.test.ts deleted file mode 100644 index 3e05be43679..00000000000 --- a/packages/frontend/editor-ui/src/app/components/Telemetry.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { useRoute } from 'vue-router'; -import { createTestingPinia } from '@pinia/testing'; -import { createComponentRenderer } from '@/__tests__/render'; -import type { MockedStore } from '@/__tests__/utils'; -import { mockedStore } from '@/__tests__/utils'; -import Telemetry from './Telemetry.vue'; -import { useRootStore } from '@n8n/stores/useRootStore'; -import { useSettingsStore } from '@/app/stores/settings.store'; -import { useUsersStore } from '@/features/settings/users/users.store'; -import { useTelemetry } from '@/app/composables/useTelemetry'; - -vi.mock('vue-router', () => { - const meta = {}; - return { - useRouter: vi.fn(), - useRoute: () => ({ - meta, - }), - RouterLink: { - template: '', - }, - }; -}); - -vi.mock('@/app/composables/useTelemetry', () => { - const init = vi.fn(); - return { - useTelemetry: () => ({ - init, - }), - }; -}); - -const renderComponent = createComponentRenderer(Telemetry, { - pinia: createTestingPinia(), -}); - -let route: ReturnType; -let rootStore: MockedStore; -let settingsStore: MockedStore; -let usersStore: MockedStore; -let telemetryPlugin: ReturnType; - -describe('Telemetry', () => { - beforeEach(() => { - vi.clearAllMocks(); - - route = useRoute(); - rootStore = mockedStore(useRootStore); - settingsStore = mockedStore(useSettingsStore); - usersStore = mockedStore(useUsersStore); - telemetryPlugin = useTelemetry(); - }); - - it('should not throw error when opened', async () => { - expect(() => renderComponent()).not.toThrow(); - }); - - it('should initialize if telemetry is enabled in settings and not disabled on the route', async () => { - settingsStore.telemetry = { - enabled: true, - }; - usersStore.currentUserId = '123'; - rootStore.instanceId = '456'; - renderComponent(); - - expect(telemetryPlugin.init).toHaveBeenCalledWith( - { - enabled: true, - }, - expect.objectContaining({ - userId: '123', - instanceId: '456', - }), - ); - }); - - it('should not initialize if telemetry is disabled in settings', async () => { - settingsStore.telemetry = { - enabled: false, - }; - renderComponent(); - - expect(telemetryPlugin.init).not.toHaveBeenCalled(); - }); - - it('should not initialize if telemetry is disabled on the route', async () => { - settingsStore.telemetry = { - enabled: true, - }; - route.meta.telemetry = { - disabled: true, - }; - renderComponent(); - - expect(telemetryPlugin.init).not.toHaveBeenCalled(); - }); - - it('should render the iframe with correct src', async () => { - settingsStore.telemetry = { - enabled: true, - }; - usersStore.currentUserId = '123'; - rootStore.instanceId = '456'; - const { container } = renderComponent(); - - const iframe = container.querySelector('iframe'); - - expect(iframe).toBeInTheDocument(); - expect(iframe).not.toBeVisible(); - expect(iframe).toHaveAttribute('src', expect.stringContaining('userId=123')); - expect(iframe).toHaveAttribute('src', expect.stringContaining('instanceId=456')); - }); - - it('should not render the iframe if telemetry disabled', async () => { - settingsStore.telemetry = { - enabled: false, - }; - usersStore.currentUserId = '123'; - rootStore.instanceId = '456'; - const { container } = renderComponent(); - - const iframe = container.querySelector('iframe'); - - expect(iframe).not.toBeInTheDocument(); - }); -}); diff --git a/packages/frontend/editor-ui/src/app/components/Telemetry.vue b/packages/frontend/editor-ui/src/app/components/Telemetry.vue deleted file mode 100644 index bb4abd790a1..00000000000 --- a/packages/frontend/editor-ui/src/app/components/Telemetry.vue +++ /dev/null @@ -1,73 +0,0 @@ - - -