refactor(core): Add breaking change rules for remaining v3 changes (#33733)

Co-authored-by: Danny Martini <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
Iván Ovejero
2026-07-07 12:44:04 +00:00
committed by GitHub
co-authored by Danny Martini Claude Fable 5
parent 41581cd818
commit 1f4e77b038
18 changed files with 806 additions and 2 deletions
@@ -10,8 +10,8 @@ describe('Breaking change rules auto-discovery', () => {
const entries = metadata.getEntries();
expect(entries.filter((entry) => entry.version === 'v2')).toHaveLength(16);
expect(entries.filter((entry) => entry.version === 'v3')).toHaveLength(2);
expect(entries).toHaveLength(18);
expect(entries.filter((entry) => entry.version === 'v3')).toHaveLength(10);
expect(entries).toHaveLength(26);
});
it('should resolve all registered rules with valid metadata from the DI container', () => {
@@ -21,4 +21,12 @@ import './v2/workflow-hooks-deprecated.rule';
// v3 rules
import './v3/always-output-data-multi-output.rule';
import './v3/compression-node-limits.rule';
import './v3/docker-only-deployment.rule';
import './v3/execute-workflow-each-mode.rule';
import './v3/get-paired-item.rule';
import './v3/in-memory-binary-data.rule';
import './v3/offload-manual-executions.rule';
import './v3/task-runner-task-timeout.rule';
import './v3/unverified-packages.rule';
import './v3/workflow-import-url-removed.rule';
@@ -0,0 +1,57 @@
import { CompressionNodeLimitsRule } from '../compression-node-limits.rule';
describe('CompressionNodeLimitsRule', () => {
let rule: CompressionNodeLimitsRule;
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES;
delete process.env.N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES;
rule = new CompressionNodeLimitsRule();
});
afterAll(() => {
process.env = originalEnv;
});
describe('detect()', () => {
it('should report both limits when neither variable is set', async () => {
const result = await rule.detect();
expect(result.isAffected).toBe(true);
expect(result.instanceIssues).toHaveLength(2);
expect(result.recommendations).toHaveLength(1);
});
it('should report only the unset limit', async () => {
process.env.N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES = '2147483648';
const result = await rule.detect();
expect(result.isAffected).toBe(true);
expect(result.instanceIssues).toHaveLength(1);
expect(result.instanceIssues[0].title).toContain('ZIP entries');
});
it('should report only the unset size limit', async () => {
process.env.N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES = '5000';
const result = await rule.detect();
expect(result.isAffected).toBe(true);
expect(result.instanceIssues).toHaveLength(1);
expect(result.instanceIssues[0].title).toContain('decompressed size');
});
it('should not be affected when both variables are set', async () => {
process.env.N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES = '2147483648';
process.env.N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES = '5000';
const result = await rule.detect();
expect(result.isAffected).toBe(false);
expect(result.instanceIssues).toHaveLength(0);
});
});
});
@@ -0,0 +1,28 @@
import type { InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import { DockerOnlyDeploymentRule } from '../docker-only-deployment.rule';
describe('DockerOnlyDeploymentRule', () => {
describe('detect()', () => {
it('should not be affected when running in a container', async () => {
const rule = new DockerOnlyDeploymentRule(mock<InstanceSettings>({ isDocker: true }));
const result = await rule.detect();
expect(result.isAffected).toBe(false);
expect(result.instanceIssues).toHaveLength(0);
});
it('should be affected when running outside a container', async () => {
const rule = new DockerOnlyDeploymentRule(mock<InstanceSettings>({ isDocker: false }));
const result = await rule.detect();
expect(result.isAffected).toBe(true);
expect(result.instanceIssues).toHaveLength(1);
expect(result.instanceIssues[0].level).toBe('warning');
expect(result.recommendations).toHaveLength(1);
});
});
});
@@ -0,0 +1,66 @@
import { createNode, createWorkflow } from '../../../__tests__/test-helpers';
import { GetPairedItemRule } from '../get-paired-item.rule';
describe('GetPairedItemRule', () => {
let rule: GetPairedItemRule;
beforeEach(() => {
rule = new GetPairedItemRule();
});
describe('detectWorkflow()', () => {
it('should not be affected when no node uses $getPairedItem', async () => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
createNode('Set', 'n8n-nodes-base.set', { value: '={{ $json.name }}' }),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
expect(result.isAffected).toBe(false);
expect(result.issues).toHaveLength(0);
});
it('should detect $getPairedItem in an expression', async () => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
createNode('Set', 'n8n-nodes-base.set', {
value: "={{ $getPairedItem('Node', $input.item) }}",
}),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
expect(result.isAffected).toBe(true);
expect(result.issues).toHaveLength(1);
expect(result.issues[0].title).toContain('Set');
expect(result.issues[0].level).toBe('error');
expect(result.issues[0].nodeName).toBe('Set');
});
it('should detect $getPairedItem in nested parameters', async () => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
createNode('HTTP', 'n8n-nodes-base.httpRequest', {
options: { headers: { value: '={{ $getPairedItem("Webhook").json.id }}' } },
}),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
expect(result.isAffected).toBe(true);
expect(result.issues).toHaveLength(1);
});
it('should flag each affected node once', async () => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
createNode('Set1', 'n8n-nodes-base.set', { value: '={{ $getPairedItem("A") }}' }),
createNode('Set2', 'n8n-nodes-base.set', { value: '={{ $json.ok }}' }),
createNode('Set3', 'n8n-nodes-base.set', { value: '={{ $getPairedItem("B") }}' }),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
expect(result.isAffected).toBe(true);
expect(result.issues).toHaveLength(2);
expect(result.issues.map((i) => i.nodeName)).toEqual(['Set1', 'Set3']);
});
});
});
@@ -0,0 +1,38 @@
import type { BinaryDataConfig } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import { InMemoryBinaryDataRule } from '../in-memory-binary-data.rule';
describe('InMemoryBinaryDataRule', () => {
let rule: InMemoryBinaryDataRule;
const binaryDataConfig: BinaryDataConfig = mock<BinaryDataConfig>();
beforeEach(() => {
rule = new InMemoryBinaryDataRule(binaryDataConfig);
});
describe('detect()', () => {
it.each(['filesystem', 's3', 'database'] as const)(
'should not be affected in %s mode',
async (mode) => {
binaryDataConfig.mode = mode;
const result = await rule.detect();
expect(result.isAffected).toBe(false);
expect(result.instanceIssues).toHaveLength(0);
},
);
it('should be affected in default (in-memory) mode', async () => {
binaryDataConfig.mode = 'default';
const result = await rule.detect();
expect(result.isAffected).toBe(true);
expect(result.instanceIssues).toHaveLength(1);
expect(result.instanceIssues[0].level).toBe('warning');
expect(result.recommendations).toHaveLength(1);
});
});
});
@@ -0,0 +1,60 @@
import type { ExecutionsConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
import { OffloadManualExecutionsRule } from '../offload-manual-executions.rule';
describe('OffloadManualExecutionsRule', () => {
let rule: OffloadManualExecutionsRule;
const executionsConfig: ExecutionsConfig = mock<ExecutionsConfig>();
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS;
rule = new OffloadManualExecutionsRule(executionsConfig);
});
afterAll(() => {
process.env = originalEnv;
});
describe('detect()', () => {
it('should not be affected in regular mode', async () => {
executionsConfig.mode = 'regular';
const result = await rule.detect();
expect(result.isAffected).toBe(false);
expect(result.instanceIssues).toHaveLength(0);
});
it('should not be affected in queue mode with offloading enabled', async () => {
executionsConfig.mode = 'queue';
process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS = 'true';
const result = await rule.detect();
expect(result.isAffected).toBe(false);
});
it('should be affected in queue mode without offloading', async () => {
executionsConfig.mode = 'queue';
const result = await rule.detect();
expect(result.isAffected).toBe(true);
expect(result.instanceIssues).toHaveLength(1);
expect(result.instanceIssues[0].level).toBe('warning');
expect(result.recommendations).toHaveLength(1);
});
it('should be affected in queue mode with offloading explicitly disabled', async () => {
executionsConfig.mode = 'queue';
process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS = 'false';
const result = await rule.detect();
expect(result.isAffected).toBe(true);
});
});
});
@@ -0,0 +1,36 @@
import { TaskRunnerTaskTimeoutRule } from '../task-runner-task-timeout.rule';
describe('TaskRunnerTaskTimeoutRule', () => {
let rule: TaskRunnerTaskTimeoutRule;
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.N8N_RUNNERS_TASK_TIMEOUT;
rule = new TaskRunnerTaskTimeoutRule();
});
afterAll(() => {
process.env = originalEnv;
});
describe('detect()', () => {
it('should be affected when the variable is not set', async () => {
const result = await rule.detect();
expect(result.isAffected).toBe(true);
expect(result.instanceIssues).toHaveLength(1);
expect(result.instanceIssues[0].level).toBe('info');
expect(result.recommendations).toHaveLength(1);
});
it('should not be affected when the variable is set', async () => {
process.env.N8N_RUNNERS_TASK_TIMEOUT = '300';
const result = await rule.detect();
expect(result.isAffected).toBe(false);
expect(result.instanceIssues).toHaveLength(0);
});
});
});
@@ -0,0 +1,56 @@
import { mock } from 'vitest-mock-extended';
import type { CommunityPackagesConfig } from '../../../../community-packages/community-packages.config';
import { UnverifiedPackagesRule } from '../unverified-packages.rule';
describe('UnverifiedPackagesRule', () => {
let rule: UnverifiedPackagesRule;
const communityPackagesConfig: CommunityPackagesConfig = mock<CommunityPackagesConfig>();
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.N8N_UNVERIFIED_PACKAGES_ENABLED;
communityPackagesConfig.enabled = true;
rule = new UnverifiedPackagesRule(communityPackagesConfig);
});
afterAll(() => {
process.env = originalEnv;
});
describe('detect()', () => {
it('should be affected when the variable is not set', async () => {
const result = await rule.detect();
expect(result.isAffected).toBe(true);
expect(result.instanceIssues).toHaveLength(1);
expect(result.instanceIssues[0].level).toBe('warning');
expect(result.recommendations).toHaveLength(1);
});
it('should not be affected when the variable is set to true', async () => {
process.env.N8N_UNVERIFIED_PACKAGES_ENABLED = 'true';
const result = await rule.detect();
expect(result.isAffected).toBe(false);
});
it('should not be affected when the variable is set to false', async () => {
process.env.N8N_UNVERIFIED_PACKAGES_ENABLED = 'false';
const result = await rule.detect();
expect(result.isAffected).toBe(false);
});
it('should not be affected when community packages are disabled', async () => {
communityPackagesConfig.enabled = false;
const result = await rule.detect();
expect(result.isAffected).toBe(false);
});
});
});
@@ -0,0 +1,20 @@
import { WorkflowImportUrlRemovedRule } from '../workflow-import-url-removed.rule';
describe('WorkflowImportUrlRemovedRule', () => {
let rule: WorkflowImportUrlRemovedRule;
beforeEach(() => {
rule = new WorkflowImportUrlRemovedRule();
});
describe('detect()', () => {
it('should always report informationally', async () => {
const result = await rule.detect();
expect(result.isAffected).toBe(true);
expect(result.instanceIssues).toHaveLength(1);
expect(result.instanceIssues[0].level).toBe('info');
expect(result.recommendations).toHaveLength(1);
});
});
});
@@ -0,0 +1,62 @@
import { BreakingChangeRule } from '@n8n/decorators';
import type {
BreakingChangeRuleMetadata,
IBreakingChangeInstanceRule,
InstanceDetectionReport,
} from '../../types';
import { BreakingChangeCategory } from '../../types';
@BreakingChangeRule({ version: 'v3' })
export class CompressionNodeLimitsRule implements IBreakingChangeInstanceRule {
id: string = 'compression-node-limits-v3';
getMetadata(): BreakingChangeRuleMetadata {
return {
version: 'v3',
title: 'Compression node limits are lowered',
description:
'The default maximum decompressed size is reduced from 2 GiB to 256 MiB and the default maximum number of ZIP entries from 5000 to 1000. Compression nodes handling archives beyond the new limits will fail.',
category: BreakingChangeCategory.environment,
severity: 'low',
};
}
async detect(): Promise<InstanceDetectionReport> {
const instanceIssues: InstanceDetectionReport['instanceIssues'] = [];
if (process.env.N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES === undefined) {
instanceIssues.push({
title: 'Instance relies on the current default decompressed size limit',
description:
'N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES is not set, so this instance uses the default of 2 GiB. After the update, decompressing more than 256 MiB will fail.',
level: 'info',
});
}
if (process.env.N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES === undefined) {
instanceIssues.push({
title: 'Instance relies on the current default ZIP entries limit',
description:
'N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES is not set, so this instance uses the default of 5000 entries. After the update, ZIP archives with more than 1000 entries will fail to decompress.',
level: 'info',
});
}
if (instanceIssues.length === 0) {
return { isAffected: false, instanceIssues: [], recommendations: [] };
}
return {
isAffected: true,
instanceIssues,
recommendations: [
{
action: 'Set the compression limits explicitly',
description:
'If your workflows decompress large archives, set N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES and N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES to keep the current limits.',
},
],
};
}
}
@@ -0,0 +1,53 @@
import { BreakingChangeRule } from '@n8n/decorators';
import { InstanceSettings } from 'n8n-core';
import type {
BreakingChangeRuleMetadata,
IBreakingChangeInstanceRule,
InstanceDetectionReport,
} from '../../types';
import { BreakingChangeCategory } from '../../types';
@BreakingChangeRule({ version: 'v3' })
export class DockerOnlyDeploymentRule implements IBreakingChangeInstanceRule {
constructor(private readonly instanceSettings: InstanceSettings) {}
id: string = 'docker-only-deployment-v3';
getMetadata(): BreakingChangeRuleMetadata {
return {
version: 'v3',
title: 'Docker becomes the only supported deployment method',
description:
'Support for running n8n via npm is removed. The new version must be run via the official Docker image.',
category: BreakingChangeCategory.infrastructure,
severity: 'medium',
documentationUrl: 'https://docs.n8n.io/deploy/host-n8n/install-options/install-with-docker',
};
}
async detect(): Promise<InstanceDetectionReport> {
if (this.instanceSettings.isDocker) {
return { isAffected: false, instanceIssues: [], recommendations: [] };
}
return {
isAffected: true,
instanceIssues: [
{
title: 'Instance is not running in a container',
description:
'This instance appears to run outside a container (e.g. installed via npm). The new version can only be run via the official Docker image.',
level: 'warning',
},
],
recommendations: [
{
action: 'Migrate to a Docker-based deployment',
description:
'Move this instance to the official Docker image before updating, reusing your existing database and encryption key.',
},
],
};
}
}
@@ -0,0 +1,65 @@
import type { BreakingChangeAffectedWorkflow, BreakingChangeRecommendation } from '@n8n/api-types';
import type { WorkflowEntity } from '@n8n/db';
import { BreakingChangeRule } from '@n8n/decorators';
import type { INode } from 'n8n-workflow';
import type {
BreakingChangeRuleMetadata,
IBreakingChangeWorkflowRule,
WorkflowDetectionReport,
} from '../../types';
import { BreakingChangeCategory } from '../../types';
const HELPER_NAME = '$getPairedItem';
@BreakingChangeRule({ version: 'v3' })
export class GetPairedItemRule implements IBreakingChangeWorkflowRule {
id: string = 'get-paired-item-v3';
getMetadata(): BreakingChangeRuleMetadata {
return {
version: 'v3',
title: '$getPairedItem expression helper is removed',
description:
'The $getPairedItem expression helper is removed. Expressions using it will fail to evaluate.',
category: BreakingChangeCategory.workflow,
severity: 'medium',
};
}
// eslint-disable-next-line @typescript-eslint/require-await
async getRecommendations(
_workflowResults: BreakingChangeAffectedWorkflow[],
): Promise<BreakingChangeRecommendation[]> {
return [
{
action: 'Replace $getPairedItem usages',
description:
'Rewrite the affected expressions using $input.all() or $("Node").all(), $("Node").item, or $("Node").itemMatching() instead of $getPairedItem.',
},
];
}
// eslint-disable-next-line @typescript-eslint/require-await
async detectWorkflow(
workflow: WorkflowEntity,
_nodesGroupedByType: Map<string, INode[]>,
): Promise<WorkflowDetectionReport> {
const affectedNodes = workflow.nodes.filter((node) =>
JSON.stringify(node.parameters).includes(HELPER_NAME),
);
if (affectedNodes.length === 0) return { isAffected: false, issues: [] };
return {
isAffected: true,
issues: affectedNodes.map((node) => ({
title: `Node '${node.name}' uses the removed ${HELPER_NAME} helper`,
description: `Expressions in this node call ${HELPER_NAME}, which is removed. They will fail to evaluate after the update.`,
level: 'error',
nodeId: node.id,
nodeName: node.name,
})),
};
}
}
@@ -0,0 +1,52 @@
import { BreakingChangeRule } from '@n8n/decorators';
import { BinaryDataConfig } from 'n8n-core';
import type {
BreakingChangeRuleMetadata,
IBreakingChangeInstanceRule,
InstanceDetectionReport,
} from '../../types';
import { BreakingChangeCategory } from '../../types';
@BreakingChangeRule({ version: 'v3' })
export class InMemoryBinaryDataRule implements IBreakingChangeInstanceRule {
constructor(private readonly binaryDataConfig: BinaryDataConfig) {}
id: string = 'in-memory-binary-data-v3';
getMetadata(): BreakingChangeRuleMetadata {
return {
version: 'v3',
title: 'In-memory binary data storage is removed',
description:
'The in-memory binary data storage mode (`default`) is removed. Instances using it must switch to `filesystem`, `s3`, or `database`.',
category: BreakingChangeCategory.infrastructure,
severity: 'medium',
};
}
async detect(): Promise<InstanceDetectionReport> {
if (this.binaryDataConfig.mode !== 'default') {
return { isAffected: false, instanceIssues: [], recommendations: [] };
}
return {
isAffected: true,
instanceIssues: [
{
title: 'Binary data is stored in memory',
description:
'This instance stores binary data in memory (`default` mode), which is removed in the new version.',
level: 'warning',
},
],
recommendations: [
{
action: 'Switch the binary data mode',
description:
'Set N8N_DEFAULT_BINARY_DATA_MODE to `filesystem`, `s3`, or `database` before updating, and ensure the chosen storage has adequate capacity.',
},
],
};
}
}
@@ -0,0 +1,54 @@
import { ExecutionsConfig } from '@n8n/config';
import { BreakingChangeRule } from '@n8n/decorators';
import type {
BreakingChangeRuleMetadata,
IBreakingChangeInstanceRule,
InstanceDetectionReport,
} from '../../types';
import { BreakingChangeCategory } from '../../types';
@BreakingChangeRule({ version: 'v3' })
export class OffloadManualExecutionsRule implements IBreakingChangeInstanceRule {
constructor(private readonly executionsConfig: ExecutionsConfig) {}
id: string = 'offload-manual-executions-v3';
getMetadata(): BreakingChangeRuleMetadata {
return {
version: 'v3',
title: 'Manual executions always run on workers in queue mode',
description:
'In queue mode, manual executions are always routed to workers. The OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS environment variable is removed and running manual executions on the main instance is no longer possible.',
category: BreakingChangeCategory.instance,
severity: 'medium',
};
}
async detect(): Promise<InstanceDetectionReport> {
const isAffected =
this.executionsConfig.mode === 'queue' &&
process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS !== 'true';
if (!isAffected) return { isAffected: false, instanceIssues: [], recommendations: [] };
return {
isAffected: true,
instanceIssues: [
{
title: 'Manual executions currently run on the main instance',
description:
'This instance runs in queue mode without offloading manual executions to workers. After the update, all manual executions run on workers, which changes where their load lands.',
level: 'warning',
},
],
recommendations: [
{
action: 'Enable offloading before updating',
description:
'Set OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true to adopt the new behavior early. Consider increasing memory available to workers and reducing memory available to main.',
},
],
};
}
}
@@ -0,0 +1,49 @@
import { BreakingChangeRule } from '@n8n/decorators';
import type {
BreakingChangeRuleMetadata,
IBreakingChangeInstanceRule,
InstanceDetectionReport,
} from '../../types';
import { BreakingChangeCategory } from '../../types';
@BreakingChangeRule({ version: 'v3' })
export class TaskRunnerTaskTimeoutRule implements IBreakingChangeInstanceRule {
id: string = 'task-runner-task-timeout-v3';
getMetadata(): BreakingChangeRuleMetadata {
return {
version: 'v3',
title: 'Task runner timeout default is reduced to 1 minute',
description:
'The default of N8N_RUNNERS_TASK_TIMEOUT is reduced from 300 seconds (5 minutes) to 60 seconds (1 minute). Code node tasks running longer than the new default will be aborted.',
category: BreakingChangeCategory.environment,
severity: 'low',
};
}
async detect(): Promise<InstanceDetectionReport> {
if (process.env.N8N_RUNNERS_TASK_TIMEOUT !== undefined) {
return { isAffected: false, instanceIssues: [], recommendations: [] };
}
return {
isAffected: true,
instanceIssues: [
{
title: 'Instance relies on the current default task timeout',
description:
'N8N_RUNNERS_TASK_TIMEOUT is not set, so this instance uses the default of 300 seconds. After the update, tasks running longer than 60 seconds will be aborted.',
level: 'info',
},
],
recommendations: [
{
action: 'Set N8N_RUNNERS_TASK_TIMEOUT explicitly',
description:
'If you have Code nodes that run longer than 1 minute, set N8N_RUNNERS_TASK_TIMEOUT=300 to keep the current timeout.',
},
],
};
}
}
@@ -0,0 +1,54 @@
import { BreakingChangeRule } from '@n8n/decorators';
import { CommunityPackagesConfig } from '../../../community-packages/community-packages.config';
import type {
BreakingChangeRuleMetadata,
IBreakingChangeInstanceRule,
InstanceDetectionReport,
} from '../../types';
import { BreakingChangeCategory } from '../../types';
@BreakingChangeRule({ version: 'v3' })
export class UnverifiedPackagesRule implements IBreakingChangeInstanceRule {
constructor(private readonly communityPackagesConfig: CommunityPackagesConfig) {}
id: string = 'unverified-packages-v3';
getMetadata(): BreakingChangeRuleMetadata {
return {
version: 'v3',
title: 'Unverified community packages are disabled by default',
description:
'The default of N8N_UNVERIFIED_PACKAGES_ENABLED changes to false. Installed community packages that are not verified by n8n will stop loading unless the variable is explicitly set to true.',
category: BreakingChangeCategory.environment,
severity: 'medium',
};
}
async detect(): Promise<InstanceDetectionReport> {
const isAffected =
this.communityPackagesConfig.enabled &&
process.env.N8N_UNVERIFIED_PACKAGES_ENABLED === undefined;
if (!isAffected) return { isAffected: false, instanceIssues: [], recommendations: [] };
return {
isAffected: true,
instanceIssues: [
{
title: 'Instance relies on the current default for unverified packages',
description:
'N8N_UNVERIFIED_PACKAGES_ENABLED is not set, so this instance currently allows unverified community packages. After the update, workflows using nodes from unverified packages will fail to load them.',
level: 'warning',
},
],
recommendations: [
{
action: 'Set N8N_UNVERIFIED_PACKAGES_ENABLED explicitly',
description:
'Review your installed community packages. If any are unverified and you want to keep using them, set N8N_UNVERIFIED_PACKAGES_ENABLED=true before updating.',
},
],
};
}
}
@@ -0,0 +1,46 @@
import { BreakingChangeRule } from '@n8n/decorators';
import type {
BreakingChangeRuleMetadata,
IBreakingChangeInstanceRule,
InstanceDetectionReport,
} from '../../types';
import { BreakingChangeCategory } from '../../types';
@BreakingChangeRule({ version: 'v3' })
export class WorkflowImportUrlRemovedRule implements IBreakingChangeInstanceRule {
id: string = 'workflow-import-url-removed-v3';
getMetadata(): BreakingChangeRuleMetadata {
return {
version: 'v3',
title: 'Workflow import from URL is removed',
description: 'The "Import from URL" option in the editor is removed.',
category: BreakingChangeCategory.instance,
severity: 'low',
};
}
// Usage of an editor feature is not detectable server-side, so this rule
// always reports informationally, like tunnel-option-v2.
async detect(): Promise<InstanceDetectionReport> {
return {
isAffected: true,
instanceIssues: [
{
title: 'Import from URL removed from the editor',
description:
'The editor no longer offers importing a workflow from a URL. If you share workflows as URLs, this will stop working after the update.',
level: 'info',
},
],
recommendations: [
{
action: 'Share workflows as files instead',
description:
'Download workflows as JSON files and use the editor\'s "Import from File" option, or exchange workflows via source control environments.',
},
],
};
}
}