feat(core): Support workflow version policies in package exports (#35962)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nour Alhadi Mahmoud
2026-08-14 08:33:54 +00:00
committed by GitHub
parent 440a14a859
commit aecd1649fe
28 changed files with 856 additions and 30 deletions
@@ -175,4 +175,36 @@ describe('ExportPackageRequestDto', () => {
expect(result.success).toBe(false);
});
});
describe('workflowVersionPolicy', () => {
it.each(['published-strict', 'prefer-published', 'ignore-unpublished', 'latest'])(
'accepts %s',
(workflowVersionPolicy) => {
const result = ExportPackageRequestDto.safeParse({
workflowIds: ['wf-1'],
workflowVersionPolicy,
});
expect(result.success).toBe(true);
},
);
it('defaults to latest', () => {
const result = ExportPackageRequestDto.safeParse({ workflowIds: ['wf-1'] });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.workflowVersionPolicy).toBe('latest');
}
});
it('rejects unknown values', () => {
const result = ExportPackageRequestDto.safeParse({
workflowIds: ['wf-1'],
workflowVersionPolicy: 'published',
});
expect(result.success).toBe(false);
});
});
});
@@ -12,4 +12,8 @@ export class ExportPackageRequestDto extends Z.class({
.enum(['fail', 'reference-only', 'include-in-package'])
.optional()
.default('fail'),
workflowVersionPolicy: z
.enum(['published-strict', 'prefer-published', 'ignore-unpublished', 'latest'])
.optional()
.default('latest'),
}) {}
@@ -29,11 +29,19 @@ n8n-cli package export -w abc --include-tags=false -o export.n8np
| `--include-variable-values` | `true` (default) or `false`. Whether values of variables referenced by the exported workflows are bundled into the package. When `false`, variables still travel as name/type files (and in the package requirements), just without their values. |
| `--include-tags` | `true` (default) or `false`. Whether tags assigned to the exported workflows are bundled into the package. When `false`, no tag data is included in the package. |
| `--missing-workflow-dependency-policy` | Policy for missing static sub-workflow dependencies: `fail` aborts when any dependency is missing, `include-in-package` automatically adds missing static sub-workflows, and `reference-only` keeps them out of the package, listing them in the package requirements as workflows expected to already exist on the target. |
| `--workflow-version-policy` | Which version of each workflow travels in the package: `latest` (default) exports the latest version whether or not it is published, `published-strict` exports the published version and aborts when any workflow has none, `prefer-published` falls back to the latest version where there is no published one, and `ignore-unpublished` leaves unpublished workflows out of the package entirely. |
Provide at least one `--workflow-id`, `--folder-id`, or `--project-id`. Requires
the API key to hold `workflow:export` when exporting workflows or folders, or
`project:export` when exporting projects.
A workflow has a latest version (what you see in the editor) and, once
published, a published version; `--workflow-version-policy` picks which one
travels. The chosen version decides which credentials, data tables, variables
and sub-workflows are bundled alongside it, but the workflow's name, settings
(including `errorWorkflow`) and tags are not versioned and always come from the
latest version.
Statically referenced sub-workflows are dependencies of the package. How
missing ones are handled depends on
`--missing-workflow-dependency-policy`. With the default `fail` policy you include them yourself. With `include-in-package`, n8n resolves the static dependency graph and adds any
@@ -153,6 +153,20 @@ describe('N8nClient packages', () => {
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe(JSON.stringify({ workflowIds: ['a'], includeTags: false }));
});
it('includes the workflow version policy when provided', async () => {
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1])));
await client.exportPackage({
workflowIds: ['a'],
workflowVersionPolicy: 'published-strict',
});
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe(
JSON.stringify({ workflowIds: ['a'], workflowVersionPolicy: 'published-strict' }),
);
});
});
describe('importPackage', () => {
@@ -17,6 +17,7 @@ interface ExportFlags {
includeVariableValues?: string;
includeTags?: string;
missingWorkflowDependencyPolicy?: string;
workflowVersionPolicy?: string;
}
/** The command methods we stub to isolate behaviour from oclif/networking. */
@@ -133,6 +134,44 @@ describe('package export command', () => {
});
});
it('forwards a non-default workflow version policy for workflows and folders', async () => {
const { command, exportPackage } = stubCommand({
workflowId: ['wf-1'],
folderId: ['fld-1'],
output: '/tmp/mixed.n8np',
workflowVersionPolicy: 'published-strict',
});
await command.run();
expect(exportPackage).toHaveBeenCalledWith({
workflowIds: ['wf-1'],
folderIds: ['fld-1'],
includeVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'published-strict',
});
});
it('forwards a non-default workflow version policy for projects', async () => {
const { command, exportPackage } = stubCommand({
projectId: ['proj-1'],
output: '/tmp/projects.n8np',
workflowVersionPolicy: 'ignore-unpublished',
});
await command.run();
expect(exportPackage).toHaveBeenCalledWith({
projectIds: ['proj-1'],
includeVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'ignore-unpublished',
});
});
it('forwards project ids and writes the archive', async () => {
const { command, exportPackage } = stubCommand({
projectId: ['proj-1', 'proj-2'],
+3
View File
@@ -38,6 +38,7 @@ export interface ExportPackageFields {
includeVariableValues?: boolean;
includeTags?: boolean;
missingWorkflowDependencyPolicy?: string;
workflowVersionPolicy?: string;
}
/** True per-entity counts of what ended up in an exported package. */
@@ -472,6 +473,7 @@ export class N8nClient {
includeVariableValues?: boolean;
includeTags?: boolean;
missingWorkflowDependencyPolicy?: string;
workflowVersionPolicy?: string;
} = {};
if (fields.workflowIds?.length) body.workflowIds = fields.workflowIds;
if (fields.folderIds?.length) body.folderIds = fields.folderIds;
@@ -481,6 +483,7 @@ export class N8nClient {
body.includeTags = fields.includeTags;
if (fields.missingWorkflowDependencyPolicy)
body.missingWorkflowDependencyPolicy = fields.missingWorkflowDependencyPolicy;
if (fields.workflowVersionPolicy) body.workflowVersionPolicy = fields.workflowVersionPolicy;
let counts: ExportPackageCounts | undefined;
const archive = await this.request<Buffer>('POST', '/n8n-packages/export', {
@@ -81,6 +81,12 @@ export default class PackageExport extends BaseCommand {
'What to do when a dependency workflow (sub-workflow) is not explicitly included in the package target',
aliases: ['missing-workflow-dependency-policy'],
}),
workflowVersionPolicy: Flags.string({
options: ['published-strict', 'prefer-published', 'ignore-unpublished', 'latest'],
default: 'latest',
description: 'Which version of each workflow travels in the package',
aliases: ['workflow-version-policy'],
}),
};
async run(): Promise<void> {
@@ -91,6 +97,7 @@ export default class PackageExport extends BaseCommand {
const includeVariableValues = flags.includeVariableValues !== 'false';
const includeTags = flags.includeTags !== 'false';
const missingWorkflowDependencyPolicy = flags.missingWorkflowDependencyPolicy;
const workflowVersionPolicy = flags.workflowVersionPolicy;
// A package is either loose workflows/folders or whole projects, not both.
if (projectIds.length > 0 && (workflowIds.length > 0 || folderIds.length > 0)) {
@@ -106,13 +113,20 @@ export default class PackageExport extends BaseCommand {
try {
result = await client.exportPackage(
projectIds.length > 0
? { projectIds, includeVariableValues, includeTags, missingWorkflowDependencyPolicy }
? {
projectIds,
includeVariableValues,
includeTags,
missingWorkflowDependencyPolicy,
workflowVersionPolicy,
}
: {
workflowIds,
folderIds,
includeVariableValues,
includeTags,
missingWorkflowDependencyPolicy,
workflowVersionPolicy,
},
);
} catch (error) {
@@ -9,6 +9,7 @@ import {
} from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { jsonParse, type INode } from 'n8n-workflow';
import { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
@@ -21,9 +22,11 @@ import { N8nPackagesService } from '../n8n-packages.service';
import { FORMAT_VERSION } from '../spec/constants';
import { readExport } from './utils/tar-support';
import {
buildVersionedWorkflow,
buildWorkflowReferencingCredential,
buildWorkflowCallingSubWorkflow,
buildWorkflowReferencingVariables,
noOpNode,
} from './utils/test-builders';
let service: N8nPackagesService;
@@ -41,7 +44,9 @@ afterAll(async () => {
beforeEach(async () => {
await testDb.truncate([
'Folder',
// WorkflowEntity first: its activeVersionId points at WorkflowHistory.
'WorkflowEntity',
'WorkflowHistory',
'SharedWorkflow',
'CredentialsEntity',
'SharedCredentials',
@@ -588,6 +593,35 @@ describe('project package export — with folders / workflows', () => {
expect(projectAEntry.target).not.toBe(projectBEntry.target);
});
it('exports a project folder workflow at its published version, skipping unpublished ones', async () => {
const owner = await createOwner();
const project = await createTeamProject('team-ligo', owner);
const folder = await createFolder(project, { name: 'in_progress' });
const { workflow: published } = await buildVersionedWorkflow({
name: 'triage',
project,
parentFolder: folder,
versions: [[noOpNode('published-v1')], [noOpNode('published-v2')]],
publishedVersion: 0,
});
await buildVersionedWorkflow({ name: 'sync', project, versions: [[noOpNode('draft-v1')]] });
const { stream } = await service.exportPackage({
user: owner,
projectIds: [project.id],
workflowVersionPolicy: 'ignore-unpublished',
});
const { manifest, entries } = await readExport(stream);
expect(manifest.workflows!.map(({ id }) => id)).toEqual([published.id]);
const exported = jsonParse<{ nodes: INode[] }>(
entries
.find((e) => e.name === `${manifest.workflows![0].target}/workflow.json`)!
.content.toString(),
);
expect(exported.nodes.map(({ name }) => name)).toEqual(['published-v1']);
});
// Telemetry
it('counts project folders, workflows and credentials in the export telemetry', async () => {
const owner = await createOwner();
@@ -9,6 +9,7 @@ import {
import type { User } from '@n8n/db';
import { ProjectRepository, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { jsonParse, type INode } from 'n8n-workflow';
import { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
@@ -19,11 +20,15 @@ import { PackageExportBlockedError } from '../entities/package-export.errors';
import { N8nPackagesService } from '../n8n-packages.service';
import { FORMAT_VERSION } from '../spec/constants';
import { readExport } from './utils/tar-support';
import type { UnpackedEntry } from './utils/tar-support';
import {
buildVersionedWorkflow,
buildWorkflowCallingSubWorkflow,
buildWorkflowReferencingCredential,
buildWorkflowUsingErrorWorkflow,
credentialNode,
executeWorkflowNode,
noOpNode,
} from './utils/test-builders';
beforeAll(async () => {
@@ -36,9 +41,25 @@ afterAll(async () => {
});
beforeEach(async () => {
await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'ProjectRelation', 'Project']);
// WorkflowEntity first: its activeVersionId points at WorkflowHistory.
await testDb.truncate([
'WorkflowEntity',
'WorkflowHistory',
'SharedWorkflow',
'ProjectRelation',
'Project',
]);
});
function workflowJson(entries: UnpackedEntry[], target: string) {
const file = entries.find((entry) => entry.name === `${target}/workflow.json`);
if (!file) throw new Error(`missing ${target}/workflow.json`);
return jsonParse<Record<string, unknown>>(file.content.toString());
}
const nodeNames = (workflow: Record<string, unknown>) =>
(workflow.nodes as INode[]).map((node) => node.name);
describe('workflow package export', () => {
let service: N8nPackagesService;
@@ -816,6 +837,210 @@ describe('workflow package export', () => {
});
});
describe('workflow version policy', () => {
it.each(['latest', 'published-strict'] as const)(
'exports a single-version workflow under %s',
async (workflowVersionPolicy) => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const { workflow } = await buildVersionedWorkflow({
name: 'Single version',
project,
versions: [[noOpNode('v1')]],
publishedVersion: 0,
});
const { stream } = await service.exportPackage({
user: owner,
workflowIds: [workflow.id],
workflowVersionPolicy,
});
const { manifest, entries } = await readExport(stream);
expect(manifest.workflows).toHaveLength(1);
expect(nodeNames(workflowJson(entries, manifest.workflows![0].target))).toEqual(['v1']);
},
);
it('exports the latest version by default even when an older version is published', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const { workflow, versionIds } = await buildVersionedWorkflow({
name: 'Published but edited',
project,
versions: [[noOpNode('v1')], [noOpNode('v2')], [noOpNode('v3')]],
publishedVersion: 1,
});
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
const { manifest, entries } = await readExport(stream);
const exported = workflowJson(entries, manifest.workflows![0].target);
expect(nodeNames(exported)).toEqual(['v3']);
expect(exported.versionId).toBe(versionIds[2]);
expect(exported.isPublished).toBe(false);
});
it('exports the published version rather than the draft', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const { workflow, versionIds } = await buildVersionedWorkflow({
name: 'Published but edited',
project,
versions: [[noOpNode('v1')], [noOpNode('v2')], [noOpNode('v3')]],
publishedVersion: 0,
settings: { executionOrder: 'v1', timezone: 'Europe/Berlin' },
});
const { stream } = await service.exportPackage({
user: owner,
workflowIds: [workflow.id],
workflowVersionPolicy: 'published-strict',
});
const { manifest, entries } = await readExport(stream);
const exported = workflowJson(entries, manifest.workflows![0].target);
expect(nodeNames(exported)).toEqual(['v1']);
expect(exported.versionId).toBe(versionIds[0]);
expect(exported.isPublished).toBe(true);
// Workflow history carries no settings, so they always come from the draft.
expect(exported.settings).toEqual({ executionOrder: 'v1', timezone: 'Europe/Berlin' });
});
it('aborts under published-strict when a workflow has no published version', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const { workflow } = await buildVersionedWorkflow({
name: 'Never published',
project,
versions: [[noOpNode('v1')], [noOpNode('v2')]],
});
await expect(
service.exportPackage({
user: owner,
workflowIds: [workflow.id],
workflowVersionPolicy: 'published-strict',
}),
).rejects.toThrow('1 workflow(s) have no published version. Export aborted.');
});
it('falls back to the draft for unpublished workflows under prefer-published', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const { workflow: published } = await buildVersionedWorkflow({
name: 'Published',
project,
versions: [[noOpNode('published-v1')], [noOpNode('published-v2')]],
publishedVersion: 0,
});
const { workflow: unpublished } = await buildVersionedWorkflow({
name: 'Never published',
project,
versions: [[noOpNode('draft-v1')], [noOpNode('draft-v2')]],
});
const { stream } = await service.exportPackage({
user: owner,
workflowIds: [published.id, unpublished.id],
workflowVersionPolicy: 'prefer-published',
});
const { manifest, entries } = await readExport(stream);
expect(manifest.workflows).toHaveLength(2);
expect(
manifest.workflows!.map(({ target }) => nodeNames(workflowJson(entries, target))),
).toEqual([['published-v1'], ['draft-v2']]);
});
it('leaves unpublished workflows out of the package under ignore-unpublished', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const { workflow: published } = await buildVersionedWorkflow({
name: 'Published',
project,
versions: [[noOpNode('published-v1')], [noOpNode('published-v2')]],
publishedVersion: 0,
});
const { workflow: unpublished } = await buildVersionedWorkflow({
name: 'Never published',
project,
versions: [[noOpNode('draft-v1')]],
});
const { stream } = await service.exportPackage({
user: owner,
workflowIds: [published.id, unpublished.id],
workflowVersionPolicy: 'ignore-unpublished',
});
const { manifest, entries } = await readExport(stream);
expect(manifest.workflows!.map(({ id }) => id)).toEqual([published.id]);
expect(entries.filter((e) => e.name.endsWith('/workflow.json'))).toHaveLength(1);
});
it('names the unpublished sub-workflow when auto-include meets ignore-unpublished', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const { workflow: child } = await buildVersionedWorkflow({
name: 'Unpublished helper',
project,
versions: [[noOpNode('child-v1')]],
});
const { workflow: parent } = await buildVersionedWorkflow({
name: 'Published caller',
project,
versions: [[executeWorkflowNode(child.id)]],
publishedVersion: 0,
});
await expect(
service.exportPackage({
user: owner,
workflowIds: [parent.id],
workflowVersionPolicy: 'ignore-unpublished',
missingWorkflowDependencyPolicy: 'include-in-package',
}),
).rejects.toThrow('1 sub-workflow dependency has no published version. Export aborted.');
});
it('bundles the credentials the exported version references, not the draft ones', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const publishedCredential = await saveCredential(
{ name: 'Published cred', type: 'httpHeaderAuth', data: { name: 'X', value: 'y' } },
{ project, role: 'credential:owner' },
);
const draftCredential = await saveCredential(
{ name: 'Draft cred', type: 'httpHeaderAuth', data: { name: 'X', value: 'y' } },
{ project, role: 'credential:owner' },
);
const { workflow } = await buildVersionedWorkflow({
name: 'Swapped credential',
project,
versions: [[credentialNode(publishedCredential)], [credentialNode(draftCredential)]],
publishedVersion: 0,
});
const { stream } = await service.exportPackage({
user: owner,
workflowIds: [workflow.id],
workflowVersionPolicy: 'published-strict',
});
const { manifest } = await readExport(stream);
expect(manifest.credentials!.map(({ id }) => id)).toEqual([publishedCredential.id]);
expect(manifest.requirements?.credentials).toEqual([
{
id: publishedCredential.id,
name: publishedCredential.name,
type: 'httpHeaderAuth',
usedByWorkflows: [workflow.id],
},
]);
});
});
describe('authorization', () => {
it('Lists count of workflows inaccessible', async () => {
const owner = await createOwner();
@@ -1,6 +1,9 @@
import { createWorkflow } from '@n8n/backend-test-utils';
import { createWorkflow, createWorkflowHistory, setActiveVersion } from '@n8n/backend-test-utils';
import type { CredentialsEntity, Folder, Project, WorkflowEntity } from '@n8n/db';
import type { INode } from 'n8n-workflow';
import { WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import type { INode, IWorkflowSettings } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
interface BuildWorkflowReferencingCredentialByIdOptions {
name: string;
@@ -208,6 +211,80 @@ export async function buildWorkflowReferencingDataTables({
);
}
export function noOpNode(name: string): INode {
return {
id: name,
name,
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
}
export function credentialNode(credential: Pick<CredentialsEntity, 'id' | 'name' | 'type'>): INode {
return {
id: `http-${credential.id}`,
name: `HTTP ${credential.name}`,
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: { [credential.type]: { id: credential.id, name: credential.name } },
};
}
interface BuildVersionedWorkflowOptions {
name: string;
project: Project;
versions: INode[][];
publishedVersion?: number;
settings?: IWorkflowSettings;
parentFolder?: Folder;
}
/**
* Snapshots each version into workflow history and leaves the workflow row holding
* the last one, so the row is the draft and any earlier version can be published.
*/
export async function buildVersionedWorkflow(
options: BuildVersionedWorkflowOptions,
): Promise<{ workflow: WorkflowEntity; versionIds: string[] }> {
const [firstVersion, ...laterVersions] = options.versions;
const workflow = await createWorkflow(
{
name: options.name,
nodes: firstVersion,
connections: {},
parentFolder: options.parentFolder,
// An explicit `undefined` would override the default `{}` and persist as null.
...(options.settings ? { settings: options.settings } : {}),
},
options.project,
);
await createWorkflowHistory(workflow);
const versionIds = [workflow.versionId];
for (const nodes of laterVersions) {
workflow.versionId = uuid();
workflow.nodes = nodes;
await Container.get(WorkflowRepository).update(workflow.id, {
versionId: workflow.versionId,
nodes,
});
await createWorkflowHistory(workflow);
versionIds.push(workflow.versionId);
}
if (options.publishedVersion !== undefined) {
const activeVersionId = versionIds[options.publishedVersion];
await setActiveVersion(workflow.id, activeVersionId);
workflow.activeVersionId = activeVersionId;
}
return { workflow, versionIds };
}
export function executeWorkflowNode(workflowId: string): INode {
return {
id: `execute-${workflowId}`,
@@ -50,6 +50,7 @@ describe('FolderExporter', () => {
folderIds: ['fld-1'],
writer: new CapturingWriter(),
includeTags: true,
workflowVersionPolicy: 'latest',
basePrefix: 'projects/team-ligo',
});
@@ -82,6 +83,7 @@ describe('FolderExporter', () => {
folderIds: ['fld-1'],
writer: new CapturingWriter(),
includeTags: true,
workflowVersionPolicy: 'latest',
});
// The folder's own target is passed as basePrefix, so workflows nest under it.
@@ -114,6 +116,7 @@ describe('FolderExporter', () => {
folderIds: ['fld-1'],
writer: new CapturingWriter(),
includeTags: true,
workflowVersionPolicy: 'latest',
}),
).rejects.toThrow(/not found or not accessible/);
});
@@ -8,6 +8,7 @@ import { FolderSerializer } from './folder.serializer';
import type { PackageWriter } from '../../io/package-writer';
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
import type { ManifestEntry } from '../../spec/manifest.schema';
import type { WorkflowVersionPolicy } from '../../n8n-packages.types';
import { assertEveryRequestedEntityAccessible } from '../package-export.errors';
import { mergeRequirements } from '../requirements.types';
import type { WorkflowExportRequirements } from '../requirements.types';
@@ -19,6 +20,7 @@ export interface FolderExportRequest {
folderIds: string[];
writer: PackageWriter;
includeTags: boolean;
workflowVersionPolicy: WorkflowVersionPolicy;
/**
* Directory the folder tree is written under. Empty for a top-level folder
* export (`folders/...`); a project exporter passes `projects/<slug>` so the
@@ -183,6 +185,7 @@ export class FolderExporter {
writer: request.writer,
workflowIds,
includeTags: request.includeTags,
workflowVersionPolicy: request.workflowVersionPolicy,
basePrefix,
});
}
@@ -97,7 +97,13 @@ describe('ProjectExporter', () => {
const { exporter, projectService } = makeExporter({ projects: [project] });
const writer = new CapturingWriter();
await exporter.export({ user, projectIds: [project.id], writer, includeTags: true });
await exporter.export({
user,
projectIds: [project.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
expect(projectService.findProjectsByIdsForUser).toHaveBeenCalledWith(
user,
@@ -112,7 +118,13 @@ describe('ProjectExporter', () => {
const writer = new CapturingWriter();
await expect(
exporter.export({ user, projectIds: [project.id], writer, includeTags: true }),
exporter.export({
user,
projectIds: [project.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
}),
).rejects.toThrow('1 project(s) not found or not accessible. Export aborted.');
});
@@ -122,7 +134,13 @@ describe('ProjectExporter', () => {
const writer = new CapturingWriter();
await expect(
exporter.export({ user, projectIds: ['missing'], writer, includeTags: true }),
exporter.export({
user,
projectIds: ['missing'],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
}),
).rejects.toBeInstanceOf(PackageEntityNotFoundError);
});
@@ -132,7 +150,13 @@ describe('ProjectExporter', () => {
const writer = new CapturingWriter();
await expect(
exporter.export({ user, projectIds: ['denied-1'], writer, includeTags: true }),
exporter.export({
user,
projectIds: ['denied-1'],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
}),
).rejects.toBeInstanceOf(PackageEntityAccessDeniedError);
});
@@ -146,6 +170,7 @@ describe('ProjectExporter', () => {
projectIds: [project.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
expect(entries).toEqual([
@@ -185,6 +210,7 @@ describe('ProjectExporter', () => {
projectIds: [newerProject.id, olderProject.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
expect(entries).toEqual([
@@ -211,6 +237,7 @@ describe('ProjectExporter', () => {
projectIds: [project.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
expect(entries).toEqual([
@@ -9,6 +9,7 @@ import { ProjectSerializer } from './project.serializer';
import type { PackageWriter } from '../../io/package-writer';
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
import type { ManifestEntry } from '../../spec/manifest.schema';
import type { WorkflowVersionPolicy } from '../../n8n-packages.types';
import { FolderExporter } from '../folder/folder.exporter';
import type { FolderExportResult } from '../folder/folder.exporter';
import { assertEveryRequestedEntityAccessible } from '../package-export.errors';
@@ -22,6 +23,7 @@ export interface ProjectExportRequest {
projectIds: string[];
writer: PackageWriter;
includeTags: boolean;
workflowVersionPolicy: WorkflowVersionPolicy;
}
interface ProjectExportResult {
@@ -111,6 +113,7 @@ export class ProjectExporter {
folderIds,
writer: request.writer,
includeTags: request.includeTags,
workflowVersionPolicy: request.workflowVersionPolicy,
basePrefix: target,
});
}
@@ -130,6 +133,7 @@ export class ProjectExporter {
workflowIds: rootWorkflowIds,
writer: request.writer,
includeTags: request.includeTags,
workflowVersionPolicy: request.workflowVersionPolicy,
basePrefix: target,
});
}
@@ -12,6 +12,7 @@ import {
} from '../../package-export.errors';
import { AutoIncludedWorkflowResolver } from '../auto-included-workflow-resolver';
import type { WorkflowSubWorkflowRequirement } from '../workflow.types';
import { WorkflowVersionPolicy } from '../../../n8n-packages.types';
const user = mock<User>({ id: 'user-1' });
@@ -105,6 +106,7 @@ function resolveInput(options: {
projectWorkflowIds: options.projectWorkflowIds ?? [],
requirements: options.requirements,
includeTags: true,
workflowVersionPolicy: WorkflowVersionPolicy.Latest,
};
}
@@ -317,6 +319,24 @@ describe('AutoIncludedWorkflowResolver', () => {
).rejects.toBeInstanceOf(PackageEntityAccessDeniedError);
});
it('names the unpublished sub-workflow when ignore-unpublished drops a dependency', async () => {
const unpublished = { ...makeWorkflow('b'), activeVersionId: null } as WorkflowEntity;
const { resolver } = makeResolver({
workflows: [makeWorkflow('seed'), unpublished],
owners: { b: makeProject('p1') },
});
await expect(
resolver.resolve({
...resolveInput({
topLevelWorkflowIds: ['seed'],
requirements: [requirement('seed', 'b')],
}),
workflowVersionPolicy: WorkflowVersionPolicy.IgnoreUnpublished,
}),
).rejects.toThrow('1 sub-workflow dependency has no published version. Export aborted.');
});
it('requests exportable workflows with the workflow:export scope and parent folder', async () => {
const { resolver, workflowFinder } = makeResolver({
workflows: [makeWorkflow('seed'), makeWorkflow('b')],
@@ -334,7 +354,7 @@ describe('AutoIncludedWorkflowResolver', () => {
['b'],
user,
['workflow:export'],
{ includeParentFolder: true, includeTags: true },
{ includeParentFolder: true, includeTags: true, includeActiveVersion: false },
);
});
});
@@ -1,4 +1,4 @@
import type { User, WorkflowEntity } from '@n8n/db';
import type { User, WorkflowEntity, WorkflowHistory } from '@n8n/db';
import type { INode } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
@@ -26,6 +26,22 @@ function makeWorkflow(id: string, referencedWorkflowIds: string | string[] = [])
return { id, nodes } as WorkflowEntity;
}
/** Publishes the workflow with nodes referencing `referencedWorkflowIds`, leaving its draft nodes alone. */
function withPublishedVersion(
workflow: WorkflowEntity,
referencedWorkflowIds: string | string[],
): WorkflowEntity {
const versionId = `${workflow.id}-published`;
workflow.activeVersionId = versionId;
workflow.activeVersion = {
versionId,
nodes: makeWorkflow(workflow.id, referencedWorkflowIds).nodes,
connections: {},
} as WorkflowHistory;
return workflow;
}
function makeResolver(workflows: WorkflowEntity[]) {
const workflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow]));
const workflowFinder = mock<WorkflowFinderService>();
@@ -49,7 +65,11 @@ describe('WorkflowDependencyResolver', () => {
makeWorkflow('workflow-c'),
]);
const requirements = await resolver.resolve({ user, workflowIds: ['workflow-a'] });
const requirements = await resolver.resolve({
user,
workflowIds: ['workflow-a'],
workflowVersionPolicy: 'latest',
});
expect(requirements).toEqual([
{ workflowId: 'workflow-a', referencedWorkflowId: 'workflow-b' },
@@ -63,7 +83,11 @@ describe('WorkflowDependencyResolver', () => {
makeWorkflow('workflow-b', 'workflow-a'),
]);
const requirements = await resolver.resolve({ user, workflowIds: ['workflow-a'] });
const requirements = await resolver.resolve({
user,
workflowIds: ['workflow-a'],
workflowVersionPolicy: 'latest',
});
expect(requirements).toEqual([
{ workflowId: 'workflow-a', referencedWorkflowId: 'workflow-b' },
@@ -75,14 +99,21 @@ describe('WorkflowDependencyResolver', () => {
it('keeps missing or inaccessible dependencies as requirements but does not traverse them', async () => {
const { resolver, workflowFinder } = makeResolver([makeWorkflow('workflow-a', 'workflow-b')]);
const requirements = await resolver.resolve({ user, workflowIds: ['workflow-a'] });
const requirements = await resolver.resolve({
user,
workflowIds: ['workflow-a'],
workflowVersionPolicy: 'latest',
});
expect(requirements).toEqual([
{ workflowId: 'workflow-a', referencedWorkflowId: 'workflow-b' },
]);
expect(workflowFinder.findWorkflowsByIdsForUser).toHaveBeenCalledWith(['workflow-b'], user, [
'workflow:export',
]);
expect(workflowFinder.findWorkflowsByIdsForUser).toHaveBeenCalledWith(
['workflow-b'],
user,
['workflow:export'],
{ includeActiveVersion: false },
);
});
it('resolves a complex graph with fan-out, cycles, convergence, and inaccessible dependencies', async () => {
@@ -96,7 +127,11 @@ describe('WorkflowDependencyResolver', () => {
makeWorkflow('workflow-e', 'workflow-b'),
]);
const requirements = await resolver.resolve({ user, workflowIds: ['workflow-a'] });
const requirements = await resolver.resolve({
user,
workflowIds: ['workflow-a'],
workflowVersionPolicy: 'latest',
});
expect(requirements).toEqual([
{ workflowId: 'workflow-a', referencedWorkflowId: 'workflow-b' },
@@ -112,18 +147,21 @@ describe('WorkflowDependencyResolver', () => {
['workflow-a'],
user,
['workflow:export'],
{ includeActiveVersion: false },
);
expect(workflowFinder.findWorkflowsByIdsForUser).toHaveBeenNthCalledWith(
2,
['workflow-b', 'workflow-c'],
user,
['workflow:export'],
{ includeActiveVersion: false },
);
expect(workflowFinder.findWorkflowsByIdsForUser).toHaveBeenNthCalledWith(
3,
['workflow-e', 'workflow-d'],
user,
['workflow:export'],
{ includeActiveVersion: false },
);
});
@@ -139,6 +177,7 @@ describe('WorkflowDependencyResolver', () => {
user,
workflowIds: ['workflow-a'],
traversal: 'direct',
workflowVersionPolicy: 'latest',
});
expect(requirements).toEqual([
@@ -157,6 +196,7 @@ describe('WorkflowDependencyResolver', () => {
user,
workflowIds: ['workflow-a', 'workflow-b'],
traversal: 'direct',
workflowVersionPolicy: 'latest',
});
expect(requirements).toEqual([
@@ -166,4 +206,21 @@ describe('WorkflowDependencyResolver', () => {
expect(workflowFinder.findWorkflowsByIdsForUser).toHaveBeenCalledTimes(1);
});
});
it('extracts the references of the published nodes under published-strict', async () => {
const { resolver } = makeResolver([
withPublishedVersion(makeWorkflow('workflow-a', 'draft-dep'), 'published-dep'),
]);
const requirements = await resolver.resolve({
user,
workflowIds: ['workflow-a'],
traversal: 'direct',
workflowVersionPolicy: 'published-strict',
});
expect(requirements).toEqual([
{ workflowId: 'workflow-a', referencedWorkflowId: 'published-dep' },
]);
});
});
@@ -0,0 +1,23 @@
import { WorkflowEntity } from '@n8n/db';
import { WorkflowVersionPolicy } from '../../../n8n-packages.types';
import { applyWorkflowVersionPolicy } from '../workflow-version-policy';
/**
* Policy selection itself is covered end to end by the export integration suites.
* This guard fires only when a caller loads workflows without the published
* version, which no export path can reach.
*/
describe('applyWorkflowVersionPolicy', () => {
it('fails loudly when the published version was not loaded', () => {
const workflow = Object.assign(new WorkflowEntity(), {
id: 'wf-1',
activeVersionId: 'wf-1-published-version',
activeVersion: null,
});
expect(() =>
applyWorkflowVersionPolicy([workflow], WorkflowVersionPolicy.PublishedStrict),
).toThrow('Published version was not loaded for workflow');
});
});
@@ -63,13 +63,19 @@ describe('WorkflowExporter', () => {
const { exporter, finder } = makeExporter([workflow]);
const writer = new CapturingWriter();
await exporter.export({ user, workflowIds: [workflow.id], writer, includeTags: true });
await exporter.export({
user,
workflowIds: [workflow.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
expect(finder.findWorkflowsByIdsForUser).toHaveBeenCalledWith(
[workflow.id],
user,
['workflow:export'],
{ includeParentFolder: true, includeTags: true },
{ includeParentFolder: true, includeTags: true, includeActiveVersion: false },
);
});
@@ -84,6 +90,7 @@ describe('WorkflowExporter', () => {
workflowIds: ['present-1', 'missing-or-denied'],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
}),
).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.');
});
@@ -95,7 +102,13 @@ describe('WorkflowExporter', () => {
const writer = new CapturingWriter();
await expect(
exporter.export({ user, workflowIds: ['present-1', 'missing'], writer, includeTags: true }),
exporter.export({
user,
workflowIds: ['present-1', 'missing'],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
}),
).rejects.toBeInstanceOf(PackageEntityNotFoundError);
});
@@ -106,7 +119,13 @@ describe('WorkflowExporter', () => {
const writer = new CapturingWriter();
await expect(
exporter.export({ user, workflowIds: ['present-1', 'denied-1'], writer, includeTags: true }),
exporter.export({
user,
workflowIds: ['present-1', 'denied-1'],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
}),
).rejects.toBeInstanceOf(PackageEntityAccessDeniedError);
});
@@ -116,7 +135,13 @@ describe('WorkflowExporter', () => {
const writer = new CapturingWriter();
await expect(
exporter.export({ user, workflowIds: ['present-1', 'missing'], writer, includeTags: true }),
exporter.export({
user,
workflowIds: ['present-1', 'missing'],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
}),
).rejects.toThrow();
expect(finder.findExistingWorkflowIds).toHaveBeenCalledWith(['missing']);
@@ -132,6 +157,7 @@ describe('WorkflowExporter', () => {
workflowIds: [workflow.id, workflow.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
expect(entries).toEqual([
@@ -153,6 +179,7 @@ describe('WorkflowExporter', () => {
workflowIds: [a.id, b.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
expect(entries.map(({ id }) => id)).toEqual([a.id, b.id]);
@@ -181,7 +208,13 @@ describe('WorkflowExporter', () => {
const { exporter } = makeExporter([workflow]);
const writer = new CapturingWriter();
await exporter.export({ user, workflowIds: [workflow.id], writer, includeTags: true });
await exporter.export({
user,
workflowIds: [workflow.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
const workflowFile = writer.files.find((f) => f.path === 'workflows/my-workflow/workflow.json');
expect(workflowFile).toBeDefined();
@@ -208,6 +241,7 @@ describe('WorkflowExporter', () => {
workflowIds: [workflow.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
basePrefix: 'folders/in_progress',
});
@@ -228,6 +262,7 @@ describe('WorkflowExporter', () => {
workflowIds: [a.id, b.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
const targets = entries.map((e) => e.target);
@@ -260,6 +295,7 @@ describe('WorkflowExporter', () => {
workflowIds: [a.id, b.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
expect(extractor.extract).toHaveBeenCalledTimes(2);
@@ -294,6 +330,7 @@ describe('WorkflowExporter', () => {
workflowIds: [a.id, b.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
expect(extractor.extract).toHaveBeenCalledTimes(2);
@@ -318,6 +355,7 @@ describe('WorkflowExporter', () => {
workflowIds: [a.id, b.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
expect(extractor.extract).toHaveBeenCalledTimes(2);
@@ -346,6 +384,7 @@ describe('WorkflowExporter', () => {
workflowIds: [a.id, b.id],
writer,
includeTags: true,
workflowVersionPolicy: 'latest',
});
expect(requirements.nodeTypes).toEqual([
@@ -10,7 +10,9 @@ import {
PackageExportBlockedError,
assertEveryRequestedEntityAccessible,
} from '../package-export.errors';
import { applyWorkflowVersionPolicy, needsActiveVersion } from './workflow-version-policy';
import type { WorkflowSubWorkflowRequirement } from './workflow.types';
import type { WorkflowVersionPolicy } from '../../n8n-packages.types';
export type WorkflowExportOrigin = 'top-level' | 'folder' | 'project';
@@ -41,6 +43,7 @@ export class AutoIncludedWorkflowResolver {
folderWorkflowIds: string[];
projectWorkflowIds: string[];
includeTags: boolean;
workflowVersionPolicy: WorkflowVersionPolicy;
}): Promise<AutoIncludedWorkflowResolution> {
const originsByWorkflowId = this.seedExportedOrigins({
topLevelWorkflowIds: options.topLevelWorkflowIds,
@@ -60,6 +63,7 @@ export class AutoIncludedWorkflowResolver {
workflowIds: autoIncludedWorkflowIds,
originsByWorkflowId,
includeTags: options.includeTags,
workflowVersionPolicy: options.workflowVersionPolicy,
});
return { autoIncludedWorkflows };
@@ -148,6 +152,7 @@ export class AutoIncludedWorkflowResolver {
workflowIds: string[];
originsByWorkflowId: Map<string, Set<WorkflowExportOrigin>>;
includeTags: boolean;
workflowVersionPolicy: WorkflowVersionPolicy;
}): Promise<AutoIncludedWorkflow[]> {
if (options.workflowIds.length === 0) return [];
@@ -155,6 +160,7 @@ export class AutoIncludedWorkflowResolver {
options.user,
options.workflowIds,
options.includeTags,
options.workflowVersionPolicy,
);
const workflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow]));
const ownersByWorkflowId = await this.sharedWorkflowRepository.findOwnerProjectsByWorkflowIds(
@@ -229,12 +235,17 @@ export class AutoIncludedWorkflowResolver {
user: User,
workflowIds: string[],
includeTags: boolean,
workflowVersionPolicy: WorkflowVersionPolicy,
): Promise<WorkflowEntity[]> {
const workflows = await this.workflowFinder.findWorkflowsByIdsForUser(
workflowIds,
user,
['workflow:export'],
{ includeParentFolder: true, includeTags },
{
includeParentFolder: true,
includeTags,
includeActiveVersion: needsActiveVersion(workflowVersionPolicy),
},
);
await assertEveryRequestedEntityAccessible(
@@ -244,7 +255,28 @@ export class AutoIncludedWorkflowResolver {
async (ids) => await this.workflowFinder.findExistingWorkflowIds(ids),
);
return workflows;
const exportableWorkflows = applyWorkflowVersionPolicy(workflows, workflowVersionPolicy);
// `ignore-unpublished` skips top-level workflows silently, but a dependency
// it drops is one the package cannot ship without — abort, naming the cause.
if (exportableWorkflows.length < workflows.length) {
const exportableIds = new Set(exportableWorkflows.map(({ id }) => id));
const droppedIds = workflows.map(({ id }) => id).filter((id) => !exportableIds.has(id));
const displayed = droppedIds.slice(0, 20);
const omittedCount = droppedIds.length - displayed.length;
const dependencyLabel = droppedIds.length === 1 ? 'dependency has' : 'dependencies have';
throw new PackageExportBlockedError(
`${droppedIds.length} sub-workflow ${dependencyLabel} no published version. Export aborted.`,
{
description: `Unpublished sub-workflow IDs: ${displayed.join(', ')}${
omittedCount > 0 ? `, and ${omittedCount} more` : ''
}`,
},
);
}
return exportableWorkflows;
}
private async findAccessibleFolderChains(
@@ -4,7 +4,9 @@ import { Service } from '@n8n/di';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { extractWorkflowRequirements } from './references/extract-workflow-requirements';
import { applyWorkflowVersionPolicy, needsActiveVersion } from './workflow-version-policy';
import type { WorkflowSubWorkflowRequirement } from './workflow.types';
import type { WorkflowVersionPolicy } from '../../n8n-packages.types';
export interface WorkflowDependencyResolveRequest {
user: User;
@@ -15,6 +17,7 @@ export interface WorkflowDependencyResolveRequest {
* workflows' own references.
*/
traversal?: 'transitive' | 'direct';
workflowVersionPolicy: WorkflowVersionPolicy;
}
@Service()
@@ -25,6 +28,7 @@ export class WorkflowDependencyResolver {
request: WorkflowDependencyResolveRequest,
): Promise<WorkflowSubWorkflowRequirement[]> {
const traverse = (request.traversal ?? 'transitive') === 'transitive';
const policy = request.workflowVersionPolicy;
const queue = [...new Set(request.workflowIds)];
const seenWorkflowIds = new Set(queue);
const requirements: WorkflowSubWorkflowRequirement[] = [];
@@ -32,11 +36,13 @@ export class WorkflowDependencyResolver {
while (queue.length > 0) {
const workflowIds = queue.splice(0);
const workflows = await this.workflowFinder.findWorkflowsByIdsForUser(
const loaded = await this.workflowFinder.findWorkflowsByIdsForUser(
workflowIds,
request.user,
['workflow:export'],
{ includeActiveVersion: needsActiveVersion(policy) },
);
const workflows = applyWorkflowVersionPolicy(loaded, policy);
const workflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow]));
for (const workflowId of workflowIds) {
@@ -0,0 +1,68 @@
import { WorkflowEntity } from '@n8n/db';
import { UnexpectedError } from 'n8n-workflow';
import { WorkflowVersionPolicy } from '../../n8n-packages.types';
import { PackageExportBlockedError } from '../package-export.errors';
export function needsActiveVersion(policy: WorkflowVersionPolicy): boolean {
return policy !== WorkflowVersionPolicy.Latest;
}
/** Only nodes and connections are overlaid, so name, description, settings and tags stay at their draft values. */
function atPublishedVersion(workflow: WorkflowEntity): WorkflowEntity {
const { activeVersion } = workflow;
if (!activeVersion) {
throw new UnexpectedError('Published version was not loaded for workflow', {
extra: { workflowId: workflow.id, activeVersionId: workflow.activeVersionId },
});
}
// A spread would drop the entity's inherited TypeORM lifecycle hooks.
return Object.assign(new WorkflowEntity(), workflow, {
versionId: activeVersion.versionId,
nodes: activeVersion.nodes,
connections: activeVersion.connections,
});
}
const isPublished = (workflow: WorkflowEntity) => workflow.activeVersionId !== null;
function assertEveryWorkflowPublished(workflows: WorkflowEntity[]): void {
const unpublished = workflows.filter((workflow) => !isPublished(workflow));
if (unpublished.length === 0) return;
const displayed = unpublished.slice(0, 20);
const omittedCount = unpublished.length - displayed.length;
throw new PackageExportBlockedError(
`${unpublished.length} workflow(s) have no published version. Export aborted.`,
{
description: `Unpublished workflow IDs: ${displayed.map(({ id }) => id).join(', ')}${
omittedCount > 0 ? `, and ${omittedCount} more` : ''
}`,
},
);
}
const WORKFLOW_VERSION_POLICIES: Record<
WorkflowVersionPolicy,
(workflows: WorkflowEntity[]) => WorkflowEntity[]
> = {
[WorkflowVersionPolicy.Latest]: (workflows) => workflows,
[WorkflowVersionPolicy.PublishedStrict]: (workflows) => {
assertEveryWorkflowPublished(workflows);
return workflows.map(atPublishedVersion);
},
[WorkflowVersionPolicy.PreferPublished]: (workflows) =>
workflows.map((workflow) => (isPublished(workflow) ? atPublishedVersion(workflow) : workflow)),
[WorkflowVersionPolicy.IgnoreUnpublished]: (workflows) =>
workflows.filter(isPublished).map(atPublishedVersion),
};
export function applyWorkflowVersionPolicy(
workflows: WorkflowEntity[],
policy: WorkflowVersionPolicy,
): WorkflowEntity[] {
return WORKFLOW_VERSION_POLICIES[policy](workflows);
}
@@ -4,7 +4,9 @@ import { Service } from '@n8n/di';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowSerializer } from './workflow.serializer';
import { applyWorkflowVersionPolicy, needsActiveVersion } from './workflow-version-policy';
import type { PackageWriter } from '../../io/package-writer';
import type { WorkflowVersionPolicy } from '../../n8n-packages.types';
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
import type { ManifestEntry } from '../../spec/manifest.schema';
import { CredentialRequirementsExtractor } from '../credential/credential-requirements.extractor';
@@ -24,6 +26,7 @@ export interface WorkflowExportRequest {
workflowIds: string[];
writer: PackageWriter;
includeTags: boolean;
workflowVersionPolicy: WorkflowVersionPolicy;
// Directory the workflow is written under. e.g. folders/{folderId}/
basePrefix?: string;
@@ -50,7 +53,11 @@ export class WorkflowExporter {
request.workflowIds,
request.user,
['workflow:export'],
{ includeParentFolder: true, includeTags: request.includeTags },
{
includeParentFolder: true,
includeTags: request.includeTags,
includeActiveVersion: needsActiveVersion(request.workflowVersionPolicy),
},
);
await assertEveryRequestedEntityAccessible(
@@ -60,7 +67,10 @@ export class WorkflowExporter {
async (ids) => await this.workflowFinder.findExistingWorkflowIds(ids),
);
const workflowsForExport = this.orderWorkflowsByRequest(request.workflowIds, workflows);
const workflowsForExport = this.orderWorkflowsByRequest(
request.workflowIds,
applyWorkflowVersionPolicy(workflows, request.workflowVersionPolicy),
);
const entries: ManifestEntry[] = [];
const credentials: WorkflowCredentialRequirement[] = [];
const dataTables: WorkflowDataTableRequirement[] = [];
@@ -32,6 +32,7 @@ import { TarPackageWriter } from './io/tar/tar-package-writer';
import { PackageImportConfig } from './n8n-packages.config';
import {
MissingWorkflowDependencyPolicy,
WorkflowVersionPolicy,
type ExportPackageEventCounts,
type ExportPackageRequest,
type ExportPackageResult,
@@ -79,6 +80,7 @@ export class N8nPackagesService {
const folderIds = request.folderIds ?? [];
const projectIds = request.projectIds ?? [];
const includeTags = (request.includeTags ?? true) && !this.globalConfig.tags.disabled;
const workflowVersionPolicy = request.workflowVersionPolicy ?? WorkflowVersionPolicy.Latest;
const folderExportResult =
folderIds.length > 0
@@ -87,6 +89,7 @@ export class N8nPackagesService {
folderIds,
writer,
includeTags,
workflowVersionPolicy,
})
: undefined;
@@ -102,6 +105,7 @@ export class N8nPackagesService {
workflowIds: workflowsForExport,
writer,
includeTags,
workflowVersionPolicy,
})
: undefined;
@@ -112,6 +116,7 @@ export class N8nPackagesService {
projectIds,
writer,
includeTags,
workflowVersionPolicy,
})
: undefined;
@@ -133,6 +138,7 @@ export class N8nPackagesService {
user: request.user,
workflowIds: allWorkflowsBeforeAutoInclude.map(({ id }) => id),
traversal: isReferenceOnly ? 'direct' : 'transitive',
workflowVersionPolicy,
});
let autoIncludedExportResult: AutoIncludedWorkflowExportResult | undefined;
@@ -145,6 +151,7 @@ export class N8nPackagesService {
folderWorkflowIds: folderExportResult?.workflowEntries.map(({ id }) => id) ?? [],
projectWorkflowIds: projectExportResult?.workflowEntries.map(({ id }) => id) ?? [],
includeTags,
workflowVersionPolicy,
});
autoIncludedExportResult = this.autoIncludedWorkflowExporter.export({
@@ -80,6 +80,17 @@ export const MissingWorkflowDependencyPolicy = {
IncludeInPackage: 'include-in-package',
} as const;
export const WorkflowVersionPolicy = {
/** Exports the latest published version, failing if any workflow has none. */
PublishedStrict: 'published-strict',
/** Exports the latest published version where there is one, the latest version otherwise. */
PreferPublished: 'prefer-published',
/** Exports only published workflows, leaving unpublished ones out of the package. */
IgnoreUnpublished: 'ignore-unpublished',
/** Exports the latest version of every workflow, published or not. */
Latest: 'latest',
} as const;
export const DataTableMatchingMode = {
/** Matches a package table to the target-project table with the same id. Never falls back to name matching. */
ById: 'by-id',
@@ -158,6 +169,9 @@ export type MissingNodeTypeMode = (typeof MissingNodeTypeMode)[keyof typeof Miss
export type MissingWorkflowDependencyPolicy =
(typeof MissingWorkflowDependencyPolicy)[keyof typeof MissingWorkflowDependencyPolicy];
export type WorkflowVersionPolicy =
(typeof WorkflowVersionPolicy)[keyof typeof WorkflowVersionPolicy];
export type DataTableMatchingMode =
(typeof DataTableMatchingMode)[keyof typeof DataTableMatchingMode];
@@ -186,6 +200,7 @@ export interface ExportPackageRequest {
canExportVariableValues?: boolean;
includeTags?: boolean;
missingWorkflowDependencyPolicy?: MissingWorkflowDependencyPolicy;
workflowVersionPolicy?: WorkflowVersionPolicy;
}
export type ImportPackageRequest = {
@@ -60,6 +60,7 @@ describe('n8n-packages handler', () => {
includeVariableValues?: boolean;
includeTags?: boolean;
missingWorkflowDependencyPolicy?: string;
workflowVersionPolicy?: string;
},
apiKeyScopes?: string[],
) {
@@ -252,6 +253,7 @@ describe('n8n-packages handler', () => {
canExportVariableValues: false,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
});
});
@@ -277,6 +279,7 @@ describe('n8n-packages handler', () => {
canExportVariableValues: false,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
});
});
@@ -376,6 +379,7 @@ describe('n8n-packages handler', () => {
canExportVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
});
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/gzip');
expect(res.setHeader).toHaveBeenCalledWith(
@@ -421,9 +425,30 @@ describe('n8n-packages handler', () => {
canExportVariableValues: false,
includeTags: true,
missingWorkflowDependencyPolicy: 'reference-only',
workflowVersionPolicy: 'latest',
});
});
it('forwards a non-default workflow version policy', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest({ workflowIds: ['wf-1'], workflowVersionPolicy: 'published-strict' }, [
'workflow:export',
]),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith(
expect.objectContaining({ workflowVersionPolicy: 'published-strict' }),
);
});
it('streams the export for a valid project request', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
@@ -446,6 +471,7 @@ describe('n8n-packages handler', () => {
canExportVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
});
});
@@ -471,6 +497,7 @@ describe('n8n-packages handler', () => {
canExportVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
});
});
@@ -496,6 +523,7 @@ describe('n8n-packages handler', () => {
canExportVariableValues: false,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
});
});
@@ -521,6 +549,7 @@ describe('n8n-packages handler', () => {
canExportVariableValues: false,
includeTags: false,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
});
});
});
@@ -36,6 +36,11 @@ type ExportPackageRequest = AuthenticatedRequest<
includeVariableValues?: boolean;
includeTags?: boolean;
missingWorkflowDependencyPolicy?: 'fail' | 'reference-only' | 'include-in-package';
workflowVersionPolicy?:
| 'published-strict'
| 'prefer-published'
| 'ignore-unpublished'
| 'latest';
}
>;
@@ -150,6 +155,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = {
canExportVariableValues: apiKeyScopes.includes('variable:list'),
includeTags: payload.data.includeTags,
missingWorkflowDependencyPolicy: payload.data.missingWorkflowDependencyPolicy,
workflowVersionPolicy: payload.data.workflowVersionPolicy,
});
return await streamPackageExport(res, exportResult);
@@ -63,3 +63,22 @@ properties:
already exist on the target.
example: fail
default: fail
workflowVersionPolicy:
type: string
enum:
- published-strict
- prefer-published
- ignore-unpublished
- latest
description: >-
Which version of each workflow travels in the package. `latest` exports
the latest version, published or not. `published-strict` exports the
published version and aborts the export if any workflow has none.
`prefer-published` falls back to the latest version where there is no
published one. `ignore-unpublished` leaves unpublished workflows out of
the package entirely. The chosen version decides which credentials, data
tables, variables and sub-workflows are bundled alongside it; the
workflow's name, settings and tags are not versioned and always come from
the latest version.
example: latest
default: latest
@@ -181,7 +181,11 @@ export class WorkflowFinderService {
workflowIds: string[],
user: User,
scopes: Scope[],
options: { includeParentFolder?: boolean; includeTags?: boolean } = {},
options: {
includeParentFolder?: boolean;
includeTags?: boolean;
includeActiveVersion?: boolean;
} = {},
): Promise<WorkflowEntity[]> {
if (workflowIds.length === 0) return [];
@@ -189,7 +193,11 @@ export class WorkflowFinderService {
const sharedWorkflows = await this.sharedWorkflowRepository.find({
where: { ...where, workflowId: In(workflowIds) },
relations: {
workflow: { parentFolder: options.includeParentFolder, tags: options.includeTags },
workflow: {
parentFolder: options.includeParentFolder,
tags: options.includeTags,
activeVersion: options.includeActiveVersion,
},
},
});