feat(core): Include workflow tags in exported packages (#34892)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jan Kalkan
2026-07-29 09:45:13 +02:00
committed by GitHub
parent fba25ed0d1
commit 811724a1c1
42 changed files with 778 additions and 32 deletions
@@ -115,6 +115,35 @@ describe('ExportPackageRequestDto', () => {
});
});
describe('includeTags', () => {
it('defaults to true when omitted', () => {
const result = ExportPackageRequestDto.safeParse({ workflowIds: ['wf-1'] });
expect(result.success).toBe(true);
if (result.success) expect(result.data.includeTags).toBe(true);
});
it.each([true, false])('accepts explicit %s', (includeTags) => {
const result = ExportPackageRequestDto.safeParse({
workflowIds: ['wf-1'],
includeTags,
});
expect(result.success).toBe(true);
if (result.success) expect(result.data.includeTags).toBe(includeTags);
});
it.each([
{ name: 'string value', includeTags: 'false' },
{ name: 'numeric value', includeTags: 0 },
{ name: 'null value', includeTags: null },
])('rejects $name', ({ includeTags }) => {
const result = ExportPackageRequestDto.safeParse({
workflowIds: ['wf-1'],
includeTags,
});
expect(result.success).toBe(false);
});
});
describe('missingWorkflowDependencyPolicy', () => {
it.each(['fail', 'reference-only', 'include-in-package'])(
'accepts %s',
@@ -7,6 +7,7 @@ export class ExportPackageRequestDto extends Z.class({
folderIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
projectIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
includeVariableValues: z.boolean().default(true),
includeTags: z.boolean().default(true),
missingWorkflowDependencyPolicy: z
.enum(['fail', 'reference-only', 'include-in-package'])
.optional()
@@ -17,6 +17,7 @@ n8n-cli package export --folder-id=xyz -o folders.n8np
n8n-cli package export --project-id=abc -o project.n8np
n8n-cli package export -p abc -p def -o projects.n8np
n8n-cli package export -w abc --include-variable-values=false -o export.n8np
n8n-cli package export -w abc --include-tags=false -o export.n8np
```
| Flag | Description |
@@ -26,6 +27,7 @@ n8n-cli package export -w abc --include-variable-values=false -o export.n8np
| `-p, --project-id` | Project ID to include. Repeat the flag to export several. |
| `-o, --output` | File to write the package to. Defaults to `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` is reserved for a future export mode. |
Provide at least one `--workflow-id`, `--folder-id`, or `--project-id`. Requires
@@ -144,6 +144,15 @@ describe('N8nClient packages', () => {
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe(JSON.stringify({ workflowIds: ['a'] }));
});
it('includes includeTags=false in the body when provided', async () => {
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1])));
await client.exportPackage({ workflowIds: ['a'], includeTags: false });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe(JSON.stringify({ workflowIds: ['a'], includeTags: false }));
});
});
describe('importPackage', () => {
@@ -15,6 +15,7 @@ interface ExportFlags {
projectId?: string[];
output: string;
includeVariableValues?: string;
includeTags?: string;
missingWorkflowDependencyPolicy?: string;
}
@@ -71,6 +72,7 @@ describe('package export command', () => {
workflowIds: ['wf-1', 'wf-2'],
folderIds: [],
includeVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/team.n8np', Buffer.from([1, 2, 3]));
@@ -88,6 +90,7 @@ describe('package export command', () => {
workflowIds: [],
folderIds: ['fld-1'],
includeVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/folders.n8np', Buffer.from([1, 2, 3]));
@@ -106,6 +109,7 @@ describe('package export command', () => {
workflowIds: ['wf-1'],
folderIds: ['fld-1'],
includeVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
});
@@ -124,6 +128,7 @@ describe('package export command', () => {
workflowIds: ['wf-1'],
folderIds: ['fld-1'],
includeVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'reference-only',
});
});
@@ -139,6 +144,7 @@ describe('package export command', () => {
expect(exportPackage).toHaveBeenCalledWith({
projectIds: ['proj-1', 'proj-2'],
includeVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/projects.n8np', Buffer.from([1, 2, 3]));
@@ -156,6 +162,7 @@ describe('package export command', () => {
expect(exportPackage).toHaveBeenCalledWith({
projectIds: ['proj-1'],
includeVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'include-in-package',
});
});
@@ -173,6 +180,7 @@ describe('package export command', () => {
workflowIds: ['wf-1'],
folderIds: [],
includeVariableValues: false,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
});
@@ -189,10 +197,71 @@ describe('package export command', () => {
expect(exportPackage).toHaveBeenCalledWith({
projectIds: ['proj-1'],
includeVariableValues: false,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
});
it('forwards includeTags=false when the flag is set', async () => {
const { command, exportPackage } = stubCommand({
workflowId: ['wf-1'],
output: '/tmp/export.n8np',
includeTags: 'false',
});
await command.run();
expect(exportPackage).toHaveBeenCalledWith({
workflowIds: ['wf-1'],
folderIds: [],
includeVariableValues: true,
includeTags: false,
missingWorkflowDependencyPolicy: 'fail',
});
});
it('forwards includeTags=false for a project export', async () => {
const { command, exportPackage } = stubCommand({
projectId: ['proj-1'],
output: '/tmp/project.n8np',
includeTags: 'false',
});
await command.run();
expect(exportPackage).toHaveBeenCalledWith({
projectIds: ['proj-1'],
includeVariableValues: true,
includeTags: false,
missingWorkflowDependencyPolicy: 'fail',
});
});
it('treats an explicit --include-tags=true like the default', async () => {
const { command, exportPackage } = stubCommand({
workflowId: ['wf-1'],
output: '/tmp/export.n8np',
includeTags: 'true',
});
await command.run();
expect(exportPackage).toHaveBeenCalledWith({
workflowIds: ['wf-1'],
folderIds: [],
includeVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
});
it('declares the includeTags flag with its alias, options, and default', () => {
const flag = PackageExport.flags.includeTags;
expect(flag.aliases).toEqual(['include-tags']);
expect(flag.options).toEqual(['true', 'false']);
expect(flag.default).toBe('true');
});
it('treats an explicit --include-variable-values=true like the default', async () => {
const { command, exportPackage } = stubCommand({
workflowId: ['wf-1'],
@@ -206,6 +275,7 @@ describe('package export command', () => {
workflowIds: ['wf-1'],
folderIds: [],
includeVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
});
@@ -275,4 +345,20 @@ describe('package export command', () => {
const message = vi.mocked(internals.succeed).mock.calls[0][0];
expect(message).toBe('Exported 3 workflow(s), 1 folder(s) to /tmp/folders.n8np');
});
it('includes a non-zero tag count in the export message', async () => {
const exportPackage = vi.fn().mockResolvedValue({
archive: Buffer.from([1, 2, 3]),
counts: { workflows: 2, folders: 0, credentials: 0, dataTables: 0, variables: 0, tags: 2 },
});
const { command, internals } = stubCommand(
{ workflowId: ['wf-1', 'wf-2'], output: '/tmp/team.n8np' },
exportPackage,
);
await command.run();
const message = vi.mocked(internals.succeed).mock.calls[0][0];
expect(message).toBe('Exported 2 workflow(s), 2 tag(s) to /tmp/team.n8np');
});
});
+5
View File
@@ -32,6 +32,7 @@ export interface ExportPackageFields {
folderIds?: string[];
projectIds?: string[];
includeVariableValues?: boolean;
includeTags?: boolean;
missingWorkflowDependencyPolicy?: string;
}
@@ -42,6 +43,8 @@ export interface ExportPackageCounts {
credentials: number;
dataTables: number;
variables: number;
/** Absent when the server predates tag export. */
tags?: number;
}
export interface ExportPackageResult {
@@ -463,6 +466,7 @@ export class N8nClient {
folderIds?: string[];
projectIds?: string[];
includeVariableValues?: boolean;
includeTags?: boolean;
missingWorkflowDependencyPolicy?: string;
} = {};
if (fields.workflowIds?.length) body.workflowIds = fields.workflowIds;
@@ -470,6 +474,7 @@ export class N8nClient {
if (fields.projectIds?.length) body.projectIds = fields.projectIds;
// `undefined` is dropped by JSON serialization, so the API's default applies.
body.includeVariableValues = fields.includeVariableValues;
body.includeTags = fields.includeTags;
if (fields.missingWorkflowDependencyPolicy)
body.missingWorkflowDependencyPolicy = fields.missingWorkflowDependencyPolicy;
@@ -19,6 +19,7 @@ function describeExport(counts: ExportPackageCounts & { projects?: number }): st
if (counts.credentials) parts.push(`${counts.credentials} credential(s)`);
if (counts.dataTables) parts.push(`${counts.dataTables} data table(s)`);
if (counts.variables) parts.push(`${counts.variables} variable(s)`);
if (counts.tags) parts.push(`${counts.tags} tag(s)`);
return parts.length > 0 ? parts.join(', ') : 'nothing';
}
@@ -32,6 +33,7 @@ export default class PackageExport extends BaseCommand {
'<%= config.bin %> package export --project-id=abc -o project.n8np',
'<%= config.bin %> package export -p abc -p def -o projects.n8np',
'<%= config.bin %> package export -w abc --include-variable-values=false -o export.n8np',
'<%= config.bin %> package export -w abc --include-tags=false -o export.n8np',
];
static override flags = {
@@ -66,6 +68,12 @@ export default class PackageExport extends BaseCommand {
default: 'true',
aliases: ['include-variable-values'],
}),
includeTags: Flags.string({
description: 'Whether tags assigned to the exported workflows are bundled into the package',
options: ['true', 'false'],
default: 'true',
aliases: ['include-tags'],
}),
missingWorkflowDependencyPolicy: Flags.string({
options: ['fail', 'reference-only', 'include-in-package'],
default: 'fail',
@@ -81,6 +89,7 @@ export default class PackageExport extends BaseCommand {
const folderIds = flags.folderId ?? [];
const projectIds = flags.projectId ?? [];
const includeVariableValues = flags.includeVariableValues !== 'false';
const includeTags = flags.includeTags !== 'false';
const missingWorkflowDependencyPolicy = flags.missingWorkflowDependencyPolicy;
// A package is either loose workflows/folders or whole projects, not both.
@@ -97,8 +106,14 @@ export default class PackageExport extends BaseCommand {
try {
result = await client.exportPackage(
projectIds.length > 0
? { projectIds, includeVariableValues, missingWorkflowDependencyPolicy }
: { workflowIds, folderIds, includeVariableValues, missingWorkflowDependencyPolicy },
? { projectIds, includeVariableValues, includeTags, missingWorkflowDependencyPolicy }
: {
workflowIds,
folderIds,
includeVariableValues,
includeTags,
missingWorkflowDependencyPolicy,
},
);
} catch (error) {
throw toPackagesError(error);
@@ -168,6 +168,7 @@ describe('LogStreamingEventRelay', () => {
credentials: 1,
dataTables: 1,
variables: 1,
tags: 1,
},
};
@@ -2313,6 +2313,7 @@ describe('TelemetryEventRelay', () => {
credentials: 2,
dataTables: 1,
variables: 4,
tags: 2,
},
};
@@ -2325,6 +2326,7 @@ describe('TelemetryEventRelay', () => {
credential_count: 2,
data_table_count: 1,
variable_count: 4,
tag_count: 2,
});
});
@@ -1087,6 +1087,7 @@ export class TelemetryEventRelay extends EventRelay {
credential_count: counts.credentials,
data_table_count: counts.dataTables,
variable_count: counts.variables,
tag_count: counts.tags,
});
}
@@ -0,0 +1,263 @@
import { LicenseState } from '@n8n/backend-common';
import { createTeamProject, createWorkflow, testDb, testModules } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { TagRepository, WorkflowTagMappingRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { jsonParse } from 'n8n-workflow';
import { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { createFolder } from '@test-integration/db/folders';
import { assignTagToWorkflow, createTag } from '@test-integration/db/tags';
import { createOwner } from '@test-integration/db/users';
import { LicenseMocker } from '@test-integration/license';
import { initNodeTypes } from '@test-integration/utils';
import { N8nPackagesService } from '../n8n-packages.service';
import { readExport, streamToBuffer } from './utils/tar-support';
import type { UnpackedEntry } from './utils/tar-support';
import { buildWorkflowCallingSubWorkflow } from './utils/test-builders';
const licenseMocker = new LicenseMocker();
beforeAll(async () => {
await testModules.loadModules(['n8n-packages']);
await testDb.init();
await initNodeTypes();
licenseMocker.mockLicenseState(Container.get(LicenseState));
});
afterAll(async () => {
await testDb.terminate();
});
beforeEach(async () => {
await testDb.truncate([
'WorkflowTagMapping',
'TagEntity',
'Folder',
'WorkflowEntity',
'SharedWorkflow',
'ProjectRelation',
'Project',
]);
});
function tagFiles(entries: UnpackedEntry[]) {
return entries.filter((entry) => entry.name.endsWith('/tag.json'));
}
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());
}
describe('workflow package export — with tags', () => {
let service: N8nPackagesService;
beforeAll(() => {
service = Container.get(N8nPackagesService);
});
it('bundles referenced tags, writes tagIds and catalogs them in manifest and requirements', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const workflow = await createWorkflow({ name: 'Tagged workflow' }, project);
// Created in reverse of sorted order so the exact-order assertions pin the sort.
const beta = await createTag({ name: 'beta' }, workflow);
const alpha = await createTag({ name: 'alpha' }, workflow);
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
try {
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
const { manifest, entries } = await readExport(stream);
expect(manifest.tags).toEqual([
{ id: alpha.id, name: 'alpha', target: 'tags/alpha' },
{ id: beta.id, name: 'beta', target: 'tags/beta' },
]);
expect(manifest.requirements!.tags).toEqual([
{ id: alpha.id, name: 'alpha', usedByWorkflows: [workflow.id] },
{ id: beta.id, name: 'beta', usedByWorkflows: [workflow.id] },
]);
const serialized = workflowJson(entries, manifest.workflows![0].target);
expect(serialized.tagIds).toEqual([alpha.id, beta.id]);
for (const entry of manifest.tags!) {
const file = entries.find((e) => e.name === `${entry.target}/tag.json`);
expect(file).toBeDefined();
const parsed = jsonParse<Record<string, unknown>>(file!.content.toString());
expect(parsed).toEqual({ id: entry.id, name: entry.name });
}
const exportedEvents = emitSpy.mock.calls.filter(([name]) => name === 'n8n-package-exported');
expect(exportedEvents).toHaveLength(1);
const payload = exportedEvents[0][1] as RelayEventMap['n8n-package-exported'];
expect(payload.counts.tags).toBe(2);
} finally {
emitSpy.mockRestore();
}
});
it('writes a tag shared by two workflows once', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const wfA = await createWorkflow({ name: 'Workflow A' }, project);
const wfB = await createWorkflow({ name: 'Workflow B' }, project);
const tag = await createTag({ name: 'shared' });
await assignTagToWorkflow(tag, wfA);
await assignTagToWorkflow(tag, wfB);
const { stream } = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id] });
const { manifest, entries } = await readExport(stream);
expect(manifest.tags).toEqual([{ id: tag.id, name: 'shared', target: 'tags/shared' }]);
expect(tagFiles(entries)).toHaveLength(1);
expect(manifest.requirements!.tags).toEqual([
{ id: tag.id, name: 'shared', usedByWorkflows: [wfA.id, wfB.id] },
]);
for (const entry of manifest.workflows!) {
expect(workflowJson(entries, entry.target).tagIds).toEqual([tag.id]);
}
});
it('exports an untagged workflow without any tag artifacts', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const workflow = await createWorkflow({ name: 'Untagged workflow' }, project);
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
const { manifest, entries } = await readExport(stream);
expect(workflowJson(entries, manifest.workflows![0].target)).not.toHaveProperty('tagIds');
expect(tagFiles(entries)).toEqual([]);
expect(manifest).not.toHaveProperty('tags');
expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array) });
});
it('with includeTags=false exports a tagged folder workflow without any tag artifacts', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const folder = await createFolder(project, { name: 'ops' });
const workflow = await createWorkflow({ name: 'In folder', parentFolder: folder }, project);
await createTag({ name: 'prod' }, workflow);
const { stream } = await service.exportPackage({
user: owner,
folderIds: [folder.id],
includeTags: false,
});
const { manifest, entries } = await readExport(stream);
expect(workflowJson(entries, manifest.workflows![0].target)).not.toHaveProperty('tagIds');
expect(tagFiles(entries)).toEqual([]);
expect(manifest).not.toHaveProperty('tags');
expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array) });
});
it('writes tags at the package root for a project export', async () => {
const owner = await createOwner();
const project = await createTeamProject('team-ligo', owner);
const folder = await createFolder(project, { name: 'ops' });
const workflow = await createWorkflow({ name: 'Deep workflow', parentFolder: folder }, project);
const tag = await createTag({ name: 'prod' }, workflow);
const { stream } = await service.exportPackage({ user: owner, projectIds: [project.id] });
const { manifest, entries } = await readExport(stream);
expect(manifest.tags).toEqual([{ id: tag.id, name: 'prod', target: 'tags/prod' }]);
expect(entries.find((e) => e.name === 'tags/prod/tag.json')).toBeDefined();
const workflowEntry = manifest.workflows!.find((entry) => entry.id === workflow.id)!;
expect(workflowEntry.target).toMatch(/^projects\//);
expect(workflowJson(entries, workflowEntry.target).tagIds).toEqual([tag.id]);
});
it('bundles the tags of auto-included sub-workflows', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const sub = await createWorkflow({ name: 'Sub workflow' }, project);
const tag = await createTag({ name: 'prod' }, sub);
const parent = await buildWorkflowCallingSubWorkflow({
name: 'Parent workflow',
project,
subWorkflowId: sub.id,
});
const { stream } = await service.exportPackage({
user: owner,
workflowIds: [parent.id],
missingWorkflowDependencyPolicy: 'include-in-package',
});
const { manifest, entries } = await readExport(stream);
expect(manifest.tags).toEqual([{ id: tag.id, name: 'prod', target: 'tags/prod' }]);
expect(manifest.requirements!.tags).toEqual([
{ id: tag.id, name: 'prod', usedByWorkflows: [sub.id] },
]);
const subEntry = manifest.workflows!.find((entry) => entry.id === sub.id)!;
expect(workflowJson(entries, subEntry.target).tagIds).toEqual([tag.id]);
});
it('skips tags silently when workflow tags are disabled on the instance', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const workflow = await createWorkflow({ name: 'Tagged workflow' }, project);
await createTag({ name: 'prod' }, workflow);
const globalConfig = Container.get(GlobalConfig);
globalConfig.tags.disabled = true;
try {
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
const { manifest, entries } = await readExport(stream);
expect(workflowJson(entries, manifest.workflows![0].target)).not.toHaveProperty('tagIds');
expect(tagFiles(entries)).toEqual([]);
expect(manifest).not.toHaveProperty('tags');
expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array) });
} finally {
globalConfig.tags.disabled = false;
}
});
it('imports a tag-bearing package as a no-op for tags', async () => {
const owner = await createOwner();
const source = await createTeamProject('Source project', owner);
const workflow = await createWorkflow({ name: 'Tagged workflow' }, source);
await createTag({ name: 'prod' }, workflow);
const packageBuffer = await streamToBuffer(
(await service.exportPackage({ user: owner, workflowIds: [workflow.id] })).stream,
);
const target = await createTeamProject('Target project', owner);
const result = await service.importPackage({
user: owner,
projectId: target.id,
packageBuffer,
credentialMatchingMode: 'id-only',
credentialMissingMode: 'must-preexist',
workflowConflictPolicy: 'fail',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
folderConflictPolicy: 'merge',
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingMode: 'do-nothing',
missingNodeTypeMode: 'fail',
});
expect(result.workflows).toHaveLength(1);
expect(result.workflows[0].status).toBe('created');
// Only the source tag and the source workflow's mapping remain — the import created neither.
const mappings = await Container.get(WorkflowTagMappingRepository).find();
expect(mappings).toHaveLength(1);
expect(mappings[0].workflowId).toBe(workflow.id);
expect(await Container.get(TagRepository).count()).toBe(1);
});
});
@@ -1,6 +1,7 @@
import type { WorkflowCredentialRequirement } from '../credential/credential.types';
import type { WorkflowDataTableRequirement } from '../data-table/data-table.types';
import { mergeRequirements } from '../requirements.types';
import type { WorkflowTagUsage } from '../tag/tag.types';
import type { WorkflowVariableRequirement } from '../variable/variable.types';
import type { WorkflowNodeTypeSource } from '../workflow/node-type-usage';
@@ -21,6 +22,10 @@ function dataTable(dataTableId: string, workflowId: string): WorkflowDataTableRe
return { workflowId, dataTableId };
}
function tagUsage(tagId: string, workflowId: string): WorkflowTagUsage {
return { workflowId, tag: { id: tagId, name: `Tag ${tagId}` } };
}
function nodeTypeSource(workflowId: string): WorkflowNodeTypeSource {
return { workflowId, nodes: [] };
}
@@ -32,12 +37,14 @@ describe('mergeRequirements', () => {
credentials: [cred('c1', 'w1')],
dataTables: [dataTable('dt1', 'w1')],
variables: [variable('V1', 'w1')],
tags: [tagUsage('t1', 'w1')],
nodeTypes: [nodeTypeSource('w1')],
},
{
credentials: [cred('c2', 'w2'), cred('c3', 'w3')],
dataTables: [dataTable('dt2', 'w2')],
variables: [variable('V2', 'w2')],
tags: [tagUsage('t2', 'w2')],
nodeTypes: [nodeTypeSource('w2')],
},
);
@@ -45,6 +52,7 @@ describe('mergeRequirements', () => {
expect(merged.credentials).toEqual([cred('c1', 'w1'), cred('c2', 'w2'), cred('c3', 'w3')]);
expect(merged.dataTables).toEqual([dataTable('dt1', 'w1'), dataTable('dt2', 'w2')]);
expect(merged.variables).toEqual([variable('V1', 'w1'), variable('V2', 'w2')]);
expect(merged.tags).toEqual([tagUsage('t1', 'w1'), tagUsage('t2', 'w2')]);
expect(merged.nodeTypes).toEqual([nodeTypeSource('w1'), nodeTypeSource('w2')]);
});
@@ -55,6 +63,7 @@ describe('mergeRequirements', () => {
credentials: [cred('c1', 'w1')],
dataTables: [dataTable('dt1', 'w1')],
variables: [variable('V1', 'w1')],
tags: [tagUsage('t1', 'w1')],
nodeTypes: [nodeTypeSource('w1')],
},
undefined,
@@ -63,6 +72,7 @@ describe('mergeRequirements', () => {
expect(merged.credentials).toEqual([cred('c1', 'w1')]);
expect(merged.dataTables).toEqual([dataTable('dt1', 'w1')]);
expect(merged.variables).toEqual([variable('V1', 'w1')]);
expect(merged.tags).toEqual([tagUsage('t1', 'w1')]);
expect(merged.nodeTypes).toEqual([nodeTypeSource('w1')]);
});
@@ -71,6 +81,7 @@ describe('mergeRequirements', () => {
credentials: [],
dataTables: [],
variables: [],
tags: [],
nodeTypes: [],
});
});
@@ -49,6 +49,7 @@ describe('FolderExporter', () => {
user,
folderIds: ['fld-1'],
writer: new CapturingWriter(),
includeTags: true,
basePrefix: 'projects/team-ligo',
});
@@ -71,6 +72,7 @@ describe('FolderExporter', () => {
],
dataTables: [],
variables: [],
tags: [],
nodeTypes: [],
},
});
@@ -79,6 +81,7 @@ describe('FolderExporter', () => {
user,
folderIds: ['fld-1'],
writer: new CapturingWriter(),
includeTags: true,
});
// The folder's own target is passed as basePrefix, so workflows nest under it.
@@ -106,7 +109,12 @@ describe('FolderExporter', () => {
);
await expect(
exporter.export({ user, folderIds: ['fld-1'], writer: new CapturingWriter() }),
exporter.export({
user,
folderIds: ['fld-1'],
writer: new CapturingWriter(),
includeTags: true,
}),
).rejects.toThrow(/not found or not accessible/);
});
});
@@ -18,6 +18,7 @@ export interface FolderExportRequest {
user: User;
folderIds: string[];
writer: PackageWriter;
includeTags: boolean;
/**
* Directory the folder tree is written under. Empty for a top-level folder
* export (`folders/...`); a project exporter passes `projects/<slug>` so the
@@ -181,6 +182,7 @@ export class FolderExporter {
user: request.user,
writer: request.writer,
workflowIds,
includeTags: request.includeTags,
basePrefix,
});
}
@@ -97,7 +97,7 @@ describe('ProjectExporter', () => {
const { exporter, projectService } = makeExporter({ projects: [project] });
const writer = new CapturingWriter();
await exporter.export({ user, projectIds: [project.id], writer });
await exporter.export({ user, projectIds: [project.id], writer, includeTags: true });
expect(projectService.findProjectsByIdsForUser).toHaveBeenCalledWith(
user,
@@ -111,9 +111,9 @@ describe('ProjectExporter', () => {
const { exporter } = makeExporter({ projects: [] });
const writer = new CapturingWriter();
await expect(exporter.export({ user, projectIds: [project.id], writer })).rejects.toThrow(
'1 project(s) not found or not accessible. Export aborted.',
);
await expect(
exporter.export({ user, projectIds: [project.id], writer, includeTags: true }),
).rejects.toThrow('1 project(s) not found or not accessible. Export aborted.');
});
it('throws PackageEntityNotFoundError when the missing project does not exist at all', async () => {
@@ -121,9 +121,9 @@ describe('ProjectExporter', () => {
projectService.findExistingProjectIds.mockResolvedValue(new Set());
const writer = new CapturingWriter();
await expect(exporter.export({ user, projectIds: ['missing'], writer })).rejects.toBeInstanceOf(
PackageEntityNotFoundError,
);
await expect(
exporter.export({ user, projectIds: ['missing'], writer, includeTags: true }),
).rejects.toBeInstanceOf(PackageEntityNotFoundError);
});
it('throws PackageEntityAccessDeniedError when the missing project exists but is inaccessible', async () => {
@@ -132,7 +132,7 @@ describe('ProjectExporter', () => {
const writer = new CapturingWriter();
await expect(
exporter.export({ user, projectIds: ['denied-1'], writer }),
exporter.export({ user, projectIds: ['denied-1'], writer, includeTags: true }),
).rejects.toBeInstanceOf(PackageEntityAccessDeniedError);
});
@@ -141,7 +141,12 @@ describe('ProjectExporter', () => {
const { exporter } = makeExporter({ projects: [project] });
const writer = new CapturingWriter();
const { entries } = await exporter.export({ user, projectIds: [project.id], writer });
const { entries } = await exporter.export({
user,
projectIds: [project.id],
writer,
includeTags: true,
});
expect(entries).toEqual([
{
@@ -179,6 +184,7 @@ describe('ProjectExporter', () => {
user,
projectIds: [newerProject.id, olderProject.id],
writer,
includeTags: true,
});
expect(entries).toEqual([
@@ -200,7 +206,12 @@ describe('ProjectExporter', () => {
const { exporter } = makeExporter({ projects: [project] });
const writer = new CapturingWriter();
const { entries } = await exporter.export({ user, projectIds: [project.id], writer });
const { entries } = await exporter.export({
user,
projectIds: [project.id],
writer,
includeTags: true,
});
expect(entries).toEqual([
{
@@ -21,6 +21,7 @@ export interface ProjectExportRequest {
user: User;
projectIds: string[];
writer: PackageWriter;
includeTags: boolean;
}
interface ProjectExportResult {
@@ -109,6 +110,7 @@ export class ProjectExporter {
user: request.user,
folderIds,
writer: request.writer,
includeTags: request.includeTags,
basePrefix: target,
});
}
@@ -127,6 +129,7 @@ export class ProjectExporter {
user: request.user,
workflowIds: rootWorkflowIds,
writer: request.writer,
includeTags: request.includeTags,
basePrefix: target,
});
}
@@ -1,5 +1,6 @@
import type { WorkflowCredentialRequirement } from './credential/credential.types';
import type { WorkflowDataTableRequirement } from './data-table/data-table.types';
import type { WorkflowTagUsage } from './tag/tag.types';
import type { WorkflowVariableRequirement } from './variable/variable.types';
import type { WorkflowNodeTypeSource } from './workflow/node-type-usage';
@@ -7,6 +8,7 @@ export interface WorkflowExportRequirements {
credentials: WorkflowCredentialRequirement[];
dataTables: WorkflowDataTableRequirement[];
variables: WorkflowVariableRequirement[];
tags: WorkflowTagUsage[];
/** Per-workflow node lists; folded into unique pairs at manifest-assembly time. */
nodeTypes: WorkflowNodeTypeSource[];
}
@@ -17,5 +19,6 @@ export const mergeRequirements = (
credentials: parts.flatMap((part) => part?.credentials ?? []),
dataTables: parts.flatMap((part) => part?.dataTables ?? []),
variables: parts.flatMap((part) => part?.variables ?? []),
tags: parts.flatMap((part) => part?.tags ?? []),
nodeTypes: parts.flatMap((part) => part?.nodeTypes ?? []),
});
@@ -0,0 +1,15 @@
import type { WorkflowEntity } from '@n8n/db';
import { Service } from '@n8n/di';
import type { WorkflowTagUsage } from './tag.types';
import type { RequirementsExtractor } from '../requirements-extractor';
@Service()
export class TagRequirementsExtractor implements RequirementsExtractor<WorkflowTagUsage> {
extract(workflow: WorkflowEntity): WorkflowTagUsage[] {
return (workflow.tags ?? []).map((tag) => ({
workflowId: workflow.id,
tag: { id: tag.id, name: tag.name },
}));
}
}
@@ -0,0 +1,55 @@
import { Service } from '@n8n/di';
import type { PackageWriter } from '../../io/package-writer';
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
import type { ManifestEntry } from '../../spec/manifest.schema';
import type { PackageTagRequirement } from '../../spec/requirements.schema';
import { serializedTagSchema } from '../../spec/serialized/tag.schema';
import { compareTagsByName, type WorkflowTagUsage } from './tag.types';
export interface TagExportRequest {
usages: WorkflowTagUsage[];
writer: PackageWriter;
}
export interface TagExportResult {
entries: ManifestEntry[];
requirements: PackageTagRequirement[];
}
@Service()
export class TagExporter {
export(request: TagExportRequest): TagExportResult {
const requirementsByTagId = new Map<string, PackageTagRequirement>();
for (const { workflowId, tag } of request.usages) {
const requirement = requirementsByTagId.get(tag.id) ?? {
id: tag.id,
name: tag.name,
usedByWorkflows: [],
};
if (!requirement.usedByWorkflows.includes(workflowId)) {
requirement.usedByWorkflows.push(workflowId);
}
requirementsByTagId.set(tag.id, requirement);
}
const requirements = [...requirementsByTagId.values()].sort(compareTagsByName);
const allocator = new UniqueFilenameAllocator('tags', 'tag');
const entries: ManifestEntry[] = [];
for (const { id, name } of requirements) {
const tagDirectory = allocator.allocate(name);
const serializedTag = serializedTagSchema.parse({ id, name });
request.writer.writeDirectory(tagDirectory);
request.writer.writeFile(
`${tagDirectory}/tag.json`,
JSON.stringify(serializedTag, null, '\t'),
);
entries.push({ id, name, target: tagDirectory });
}
return { entries, requirements };
}
}
@@ -0,0 +1,10 @@
export interface WorkflowTagUsage {
workflowId: string;
tag: { id: string; name: string };
}
/** Locale pinned so package output is byte-stable across environments. */
export const compareTagsByName = (
a: { id: string; name: string },
b: { id: string; name: string },
) => a.name.localeCompare(b.name, 'en') || a.id.localeCompare(b.id, 'en');
@@ -104,6 +104,7 @@ function resolveInput(options: {
folderWorkflowIds: options.folderWorkflowIds ?? [],
projectWorkflowIds: options.projectWorkflowIds ?? [],
requirements: options.requirements,
includeTags: true,
};
}
@@ -333,7 +334,7 @@ describe('AutoIncludedWorkflowResolver', () => {
['b'],
user,
['workflow:export'],
{ includeParentFolder: true },
{ includeParentFolder: true, includeTags: true },
);
});
});
@@ -9,6 +9,7 @@ import { CredentialRequirementsExtractor } from '../../credential/credential-req
import { DataTableRequirementsExtractor } from '../../data-table/data-table-requirements.extractor';
import { FolderSerializer } from '../../folder/folder.serializer';
import { ProjectSerializer } from '../../project/project.serializer';
import { TagRequirementsExtractor } from '../../tag/tag-requirements.extractor';
import { VariableRequirementsExtractor } from '../../variable/variable-requirements.extractor';
import type { AutoIncludedWorkflow } from '../auto-included-workflow-resolver';
import { AutoIncludedWorkflowExporter } from '../auto-included-workflow.exporter';
@@ -59,6 +60,7 @@ function makeExporter(
credentialExtractor ?? new CredentialRequirementsExtractor(),
dataTableExtractor ?? new DataTableRequirementsExtractor(),
variableExtractor ?? new VariableRequirementsExtractor(),
new TagRequirementsExtractor(),
);
}
@@ -69,6 +71,7 @@ function emptyRequest(writer: CapturingWriter, workflows: AutoIncludedWorkflow[]
existingWorkflowEntries: [] as ManifestEntry[],
existingFolderEntries: [] as ManifestEntry[],
existingProjectEntries: [] as ManifestEntry[],
includeTags: true,
};
}
@@ -14,6 +14,7 @@ import {
PackageEntityAccessDeniedError,
PackageEntityNotFoundError,
} from '../../package-export.errors';
import { TagRequirementsExtractor } from '../../tag/tag-requirements.extractor';
import { VariableRequirementsExtractor } from '../../variable/variable-requirements.extractor';
import type { WorkflowVariableRequirement } from '../../variable/variable.types';
import { WorkflowExporter } from '../workflow.exporter';
@@ -51,6 +52,7 @@ function makeExporter(
credentialExtractor ?? new CredentialRequirementsExtractor(),
dataTableExtractor ?? new DataTableRequirementsExtractor(),
variableExtractor ?? new VariableRequirementsExtractor(),
new TagRequirementsExtractor(),
);
return { exporter, finder };
}
@@ -61,13 +63,13 @@ describe('WorkflowExporter', () => {
const { exporter, finder } = makeExporter([workflow]);
const writer = new CapturingWriter();
await exporter.export({ user, workflowIds: [workflow.id], writer });
await exporter.export({ user, workflowIds: [workflow.id], writer, includeTags: true });
expect(finder.findWorkflowsByIdsForUser).toHaveBeenCalledWith(
[workflow.id],
user,
['workflow:export'],
{ includeParentFolder: true },
{ includeParentFolder: true, includeTags: true },
);
});
@@ -81,6 +83,7 @@ describe('WorkflowExporter', () => {
user,
workflowIds: ['present-1', 'missing-or-denied'],
writer,
includeTags: true,
}),
).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.');
});
@@ -92,7 +95,7 @@ describe('WorkflowExporter', () => {
const writer = new CapturingWriter();
await expect(
exporter.export({ user, workflowIds: ['present-1', 'missing'], writer }),
exporter.export({ user, workflowIds: ['present-1', 'missing'], writer, includeTags: true }),
).rejects.toBeInstanceOf(PackageEntityNotFoundError);
});
@@ -103,7 +106,7 @@ describe('WorkflowExporter', () => {
const writer = new CapturingWriter();
await expect(
exporter.export({ user, workflowIds: ['present-1', 'denied-1'], writer }),
exporter.export({ user, workflowIds: ['present-1', 'denied-1'], writer, includeTags: true }),
).rejects.toBeInstanceOf(PackageEntityAccessDeniedError);
});
@@ -113,7 +116,7 @@ describe('WorkflowExporter', () => {
const writer = new CapturingWriter();
await expect(
exporter.export({ user, workflowIds: ['present-1', 'missing'], writer }),
exporter.export({ user, workflowIds: ['present-1', 'missing'], writer, includeTags: true }),
).rejects.toThrow();
expect(finder.findExistingWorkflowIds).toHaveBeenCalledWith(['missing']);
@@ -128,6 +131,7 @@ describe('WorkflowExporter', () => {
user,
workflowIds: [workflow.id, workflow.id],
writer,
includeTags: true,
});
expect(entries).toEqual([
@@ -148,6 +152,7 @@ describe('WorkflowExporter', () => {
user,
workflowIds: [a.id, b.id],
writer,
includeTags: true,
});
expect(entries.map(({ id }) => id)).toEqual([a.id, b.id]);
@@ -176,7 +181,7 @@ describe('WorkflowExporter', () => {
const { exporter } = makeExporter([workflow]);
const writer = new CapturingWriter();
await exporter.export({ user, workflowIds: [workflow.id], writer });
await exporter.export({ user, workflowIds: [workflow.id], writer, includeTags: true });
const workflowFile = writer.files.find((f) => f.path === 'workflows/my-workflow/workflow.json');
expect(workflowFile).toBeDefined();
@@ -202,6 +207,7 @@ describe('WorkflowExporter', () => {
user,
workflowIds: [workflow.id],
writer,
includeTags: true,
basePrefix: 'folders/in_progress',
});
@@ -217,7 +223,12 @@ describe('WorkflowExporter', () => {
const { exporter } = makeExporter([a, b]);
const writer = new CapturingWriter();
const { entries } = await exporter.export({ user, workflowIds: [a.id, b.id], writer });
const { entries } = await exporter.export({
user,
workflowIds: [a.id, b.id],
writer,
includeTags: true,
});
const targets = entries.map((e) => e.target);
expect(targets).toEqual(['workflows/same-name', 'workflows/same-name-2']);
@@ -248,6 +259,7 @@ describe('WorkflowExporter', () => {
user,
workflowIds: [a.id, b.id],
writer,
includeTags: true,
});
expect(extractor.extract).toHaveBeenCalledTimes(2);
@@ -281,6 +293,7 @@ describe('WorkflowExporter', () => {
user,
workflowIds: [a.id, b.id],
writer,
includeTags: true,
});
expect(extractor.extract).toHaveBeenCalledTimes(2);
@@ -304,6 +317,7 @@ describe('WorkflowExporter', () => {
user,
workflowIds: [a.id, b.id],
writer,
includeTags: true,
});
expect(extractor.extract).toHaveBeenCalledTimes(2);
@@ -331,6 +345,7 @@ describe('WorkflowExporter', () => {
user,
workflowIds: [a.id, b.id],
writer,
includeTags: true,
});
expect(requirements.nodeTypes).toEqual([
@@ -40,6 +40,7 @@ export class AutoIncludedWorkflowResolver {
topLevelWorkflowIds: string[];
folderWorkflowIds: string[];
projectWorkflowIds: string[];
includeTags: boolean;
}): Promise<AutoIncludedWorkflowResolution> {
const originsByWorkflowId = this.seedExportedOrigins({
topLevelWorkflowIds: options.topLevelWorkflowIds,
@@ -58,6 +59,7 @@ export class AutoIncludedWorkflowResolver {
user: options.user,
workflowIds: autoIncludedWorkflowIds,
originsByWorkflowId,
includeTags: options.includeTags,
});
return { autoIncludedWorkflows };
@@ -145,10 +147,15 @@ export class AutoIncludedWorkflowResolver {
user: User;
workflowIds: string[];
originsByWorkflowId: Map<string, Set<WorkflowExportOrigin>>;
includeTags: boolean;
}): Promise<AutoIncludedWorkflow[]> {
if (options.workflowIds.length === 0) return [];
const workflows = await this.findExportableWorkflows(options.user, options.workflowIds);
const workflows = await this.findExportableWorkflows(
options.user,
options.workflowIds,
options.includeTags,
);
const workflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow]));
const ownersByWorkflowId = await this.sharedWorkflowRepository.findOwnerProjectsByWorkflowIds(
options.workflowIds,
@@ -221,12 +228,13 @@ export class AutoIncludedWorkflowResolver {
private async findExportableWorkflows(
user: User,
workflowIds: string[],
includeTags: boolean,
): Promise<WorkflowEntity[]> {
const workflows = await this.workflowFinder.findWorkflowsByIdsForUser(
workflowIds,
user,
['workflow:export'],
{ includeParentFolder: true },
{ includeParentFolder: true, includeTags },
);
await assertEveryRequestedEntityAccessible(
@@ -15,6 +15,8 @@ import type { WorkflowDataTableRequirement } from '../data-table/data-table.type
import { FolderSerializer } from '../folder/folder.serializer';
import { ProjectSerializer } from '../project/project.serializer';
import type { WorkflowExportRequirements } from '../requirements.types';
import { TagRequirementsExtractor } from '../tag/tag-requirements.extractor';
import type { WorkflowTagUsage } from '../tag/tag.types';
import { VariableRequirementsExtractor } from '../variable/variable-requirements.extractor';
import type { WorkflowVariableRequirement } from '../variable/variable.types';
@@ -24,6 +26,7 @@ export interface AutoIncludedWorkflowExportRequest {
existingWorkflowEntries: ManifestEntry[];
existingFolderEntries: ManifestEntry[];
existingProjectEntries: ManifestEntry[];
includeTags: boolean;
projectTargetsById?: Map<string, string>;
}
@@ -54,6 +57,7 @@ export class AutoIncludedWorkflowExporter {
private readonly credentialRequirementsExtractor: CredentialRequirementsExtractor,
private readonly dataTableRequirementsExtractor: DataTableRequirementsExtractor,
private readonly variableRequirementsExtractor: VariableRequirementsExtractor,
private readonly tagRequirementsExtractor: TagRequirementsExtractor,
) {}
export(request: AutoIncludedWorkflowExportRequest): AutoIncludedWorkflowExportResult {
@@ -93,6 +97,7 @@ export class AutoIncludedWorkflowExporter {
const credentials: WorkflowCredentialRequirement[] = [];
const dataTables: WorkflowDataTableRequirement[] = [];
const variables: WorkflowVariableRequirement[] = [];
const tags: WorkflowTagUsage[] = [];
const nodeTypes: WorkflowNodeTypeSource[] = [];
for (const included of request.workflows) {
@@ -113,12 +118,14 @@ export class AutoIncludedWorkflowExporter {
baseDir,
request.writer,
allocators.workflows,
request.includeTags,
);
workflowEntries.push(entry);
workflowEntriesById.set(entry.id, entry);
credentials.push(...this.credentialRequirementsExtractor.extract(included.workflow));
dataTables.push(...this.dataTableRequirementsExtractor.extract(included.workflow));
variables.push(...this.variableRequirementsExtractor.extract(included.workflow));
tags.push(...this.tagRequirementsExtractor.extract(included.workflow));
nodeTypes.push({
workflowId: included.workflow.id,
nodes: included.workflow.nodes ?? [],
@@ -129,7 +136,7 @@ export class AutoIncludedWorkflowExporter {
workflowEntries,
folderEntries,
projectEntries,
requirements: { credentials, dataTables, variables, nodeTypes },
requirements: { credentials, dataTables, variables, tags, nodeTypes },
projectTargetsById,
};
}
@@ -261,9 +268,10 @@ export class AutoIncludedWorkflowExporter {
baseDir: string,
writer: PackageWriter,
allocators: Map<string, UniqueFilenameAllocator>,
includeTags: boolean,
): ManifestEntry {
const target = allocatorFor(allocators, baseDir, 'workflow').allocate(workflow.name);
const serialized = this.workflowSerializer.serialize(workflow);
const serialized = this.workflowSerializer.serialize(workflow, { includeTags });
writer.writeDirectory(target);
writer.writeFile(`${target}/workflow.json`, JSON.stringify(serialized, null, '\t'));
return { id: workflow.id, name: workflow.name, target };
@@ -14,6 +14,8 @@ import type { WorkflowDataTableRequirement } from '../data-table/data-table.type
import type { WorkflowNodeTypeSource } from './node-type-usage';
import { assertEveryRequestedEntityAccessible } from '../package-export.errors';
import type { WorkflowExportRequirements } from '../requirements.types';
import { TagRequirementsExtractor } from '../tag/tag-requirements.extractor';
import type { WorkflowTagUsage } from '../tag/tag.types';
import { VariableRequirementsExtractor } from '../variable/variable-requirements.extractor';
import type { WorkflowVariableRequirement } from '../variable/variable.types';
@@ -21,6 +23,7 @@ export interface WorkflowExportRequest {
user: User;
workflowIds: string[];
writer: PackageWriter;
includeTags: boolean;
// Directory the workflow is written under. e.g. folders/{folderId}/
basePrefix?: string;
@@ -39,6 +42,7 @@ export class WorkflowExporter {
private readonly credentialRequirementsExtractor: CredentialRequirementsExtractor,
private readonly dataTableRequirementsExtractor: DataTableRequirementsExtractor,
private readonly variableRequirementsExtractor: VariableRequirementsExtractor,
private readonly tagRequirementsExtractor: TagRequirementsExtractor,
) {}
async export(request: WorkflowExportRequest): Promise<WorkflowExportResult> {
@@ -46,7 +50,7 @@ export class WorkflowExporter {
request.workflowIds,
request.user,
['workflow:export'],
{ includeParentFolder: true },
{ includeParentFolder: true, includeTags: request.includeTags },
);
await assertEveryRequestedEntityAccessible(
@@ -61,6 +65,7 @@ export class WorkflowExporter {
const credentials: WorkflowCredentialRequirement[] = [];
const dataTables: WorkflowDataTableRequirement[] = [];
const variables: WorkflowVariableRequirement[] = [];
const tags: WorkflowTagUsage[] = [];
const nodeTypes: WorkflowNodeTypeSource[] = [];
const fileNames = new UniqueFilenameAllocator(
request.basePrefix ? `${request.basePrefix}/workflows` : 'workflows',
@@ -69,7 +74,9 @@ export class WorkflowExporter {
for (const workflow of workflowsForExport) {
const target = fileNames.allocate(workflow.name);
const serialized = this.workflowSerializer.serialize(workflow);
const serialized = this.workflowSerializer.serialize(workflow, {
includeTags: request.includeTags,
});
request.writer.writeDirectory(target);
request.writer.writeFile(`${target}/workflow.json`, JSON.stringify(serialized, null, '\t'));
@@ -83,10 +90,11 @@ export class WorkflowExporter {
credentials.push(...this.credentialRequirementsExtractor.extract(workflow));
dataTables.push(...this.dataTableRequirementsExtractor.extract(workflow));
variables.push(...this.variableRequirementsExtractor.extract(workflow));
tags.push(...this.tagRequirementsExtractor.extract(workflow));
nodeTypes.push({ workflowId: workflow.id, nodes: workflow.nodes ?? [] });
}
return { entries, requirements: { credentials, dataTables, variables, nodeTypes } };
return { entries, requirements: { credentials, dataTables, variables, tags, nodeTypes } };
}
private orderWorkflowsByRequest(
@@ -6,6 +6,7 @@ import {
serializedWorkflowSchema,
type SerializedWorkflow,
} from '../../spec/serialized/workflow.schema';
import { compareTagsByName } from '../tag/tag.types';
/** Fields restored from package workflow.json; the target instance assigns the rest. */
type WorkflowPackageContent = Pick<
@@ -15,7 +16,12 @@ type WorkflowPackageContent = Pick<
@Service()
export class WorkflowSerializer {
serialize(workflow: WorkflowEntity): SerializedWorkflow {
serialize(workflow: WorkflowEntity, options: { includeTags: boolean }): SerializedWorkflow {
const tags =
options.includeTags && workflow.tags?.length
? [...workflow.tags].sort(compareTagsByName)
: undefined;
return serializedWorkflowSchema.parse({
id: workflow.id,
name: workflow.name,
@@ -26,6 +32,7 @@ export class WorkflowSerializer {
parentFolderId: workflow.parentFolder?.id ?? null,
isPublished: workflow.activeVersionId === workflow.versionId,
isArchived: workflow.isArchived,
...(tags ? { tagIds: tags.map((tag) => tag.id) } : {}),
});
}
@@ -1,3 +1,4 @@
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
@@ -15,6 +16,7 @@ import { FolderExporter } from './entities/folder/folder.exporter';
import { PackageExportBlockedError } from './entities/package-export.errors';
import { ProjectExporter } from './entities/project/project.exporter';
import { mergeRequirements } from './entities/requirements.types';
import { TagExporter } from './entities/tag/tag.exporter';
import { VariableExporter } from './entities/variable/variable.exporter';
import { collectNodeTypeUsage } from './entities/workflow/node-type-usage';
import { assertStaticSubWorkflowsIncluded } from './entities/workflow/static-sub-workflow-requirements';
@@ -54,6 +56,8 @@ export class N8nPackagesService {
private readonly credentialExporter: CredentialExporter,
private readonly dataTableExporter: DataTableExporter,
private readonly variableExporter: VariableExporter,
private readonly tagExporter: TagExporter,
private readonly globalConfig: GlobalConfig,
private readonly instanceSettings: InstanceSettings,
private readonly packageParser: N8nPackageParser,
private readonly packageImportConfig: PackageImportConfig,
@@ -79,6 +83,7 @@ export class N8nPackagesService {
const workflowIds = request.workflowIds ?? [];
const folderIds = request.folderIds ?? [];
const projectIds = request.projectIds ?? [];
const includeTags = (request.includeTags ?? true) && !this.globalConfig.tags.disabled;
const folderExportResult =
folderIds.length > 0
@@ -86,6 +91,7 @@ export class N8nPackagesService {
user: request.user,
folderIds,
writer,
includeTags,
})
: undefined;
@@ -100,6 +106,7 @@ export class N8nPackagesService {
user: request.user,
workflowIds: workflowsForExport,
writer,
includeTags,
})
: undefined;
@@ -109,6 +116,7 @@ export class N8nPackagesService {
user: request.user,
projectIds,
writer,
includeTags,
})
: undefined;
@@ -137,6 +145,7 @@ export class N8nPackagesService {
topLevelWorkflowIds: workflowExportResult?.entries.map(({ id }) => id) ?? [],
folderWorkflowIds: folderExportResult?.workflowEntries.map(({ id }) => id) ?? [],
projectWorkflowIds: projectExportResult?.workflowEntries.map(({ id }) => id) ?? [],
includeTags,
});
autoIncludedExportResult = this.autoIncludedWorkflowExporter.export({
@@ -145,6 +154,7 @@ export class N8nPackagesService {
existingWorkflowEntries: allWorkflowsBeforeAutoInclude,
existingFolderEntries: allFoldersBeforeAutoInclude,
existingProjectEntries: allProjectsBeforeAutoInclude,
includeTags,
projectTargetsById: projectExportResult?.projectTargetsById,
});
}
@@ -219,11 +229,17 @@ export class N8nPackagesService {
projectTargetsById,
});
const tagExportResult = this.tagExporter.export({
usages: requirements.tags,
writer,
});
const manifestRequirements = this.buildManifestRequirements({
credentials: credentialExportResult.requirements,
dataTables: dataTableExportResult.requirements,
workflows: workflowRequirementExportResult.requirements,
variables: variableExportResult.requirements,
tags: tagExportResult.requirements,
nodeTypes: collectNodeTypeUsage(requirements.nodeTypes),
});
@@ -241,6 +257,7 @@ export class N8nPackagesService {
...(variableExportResult.entries.length > 0
? { variables: variableExportResult.entries }
: {}),
...(tagExportResult.entries.length > 0 ? { tags: tagExportResult.entries } : {}),
...(manifestRequirements ? { requirements: manifestRequirements } : {}),
...(allWorkflowsInPackage.length > 0 ? { workflows: allWorkflowsInPackage } : {}),
...(allFolders.length > 0 ? { folders: allFolders } : {}),
@@ -257,6 +274,7 @@ export class N8nPackagesService {
credentials: credentialExportResult.entries.length,
dataTables: dataTableExportResult.entries.length,
variables: variableExportResult.entries.length,
tags: tagExportResult.entries.length,
};
this.eventService.emit('n8n-package-exported', {
@@ -306,15 +324,17 @@ export class N8nPackagesService {
dataTables: PackageRequirements['dataTables'];
workflows: PackageRequirements['workflows'];
variables: PackageRequirements['variables'];
tags: PackageRequirements['tags'];
nodeTypes: PackageRequirements['nodeTypes'];
}): PackageRequirements | undefined {
const { credentials, dataTables, workflows, variables, nodeTypes } = input;
const { credentials, dataTables, workflows, variables, tags, nodeTypes } = input;
const requirements: PackageRequirements = {
...(credentials?.length ? { credentials } : {}),
...(dataTables?.length ? { dataTables } : {}),
...(workflows?.length ? { workflows } : {}),
...(variables?.length ? { variables } : {}),
...(tags?.length ? { tags } : {}),
...(nodeTypes?.length ? { nodeTypes } : {}),
};
return Object.keys(requirements).length > 0 ? requirements : undefined;
@@ -132,6 +132,7 @@ export interface ExportPackageRequest {
projectIds?: string[];
includeVariableValues?: boolean;
canExportVariableValues?: boolean;
includeTags?: boolean;
missingWorkflowDependencyPolicy?: MissingWorkflowDependencyPolicy;
}
@@ -235,6 +236,7 @@ export type ExportPackageEventCounts = {
credentials: number;
dataTables: number;
variables: number;
tags: number;
};
/**
@@ -142,6 +142,18 @@ describe('packageManifestSchema', () => {
expect(packageManifestSchema.parse(manifest).variables).toEqual(manifest.variables);
});
it('rejects a manifest containing duplicate tag ids', () => {
const manifest = {
...validManifest,
tags: [
{ id: 'tag-1', name: 'production', target: 'tags/production' },
{ id: 'tag-1', name: 'production', target: 'tags/production-2' },
],
};
expect(() => packageManifestSchema.parse(manifest)).toThrow(/duplicate tag id/i);
});
it('accepts manifests with unknown sections for forward compatibility', () => {
const manifest = {
...validManifest,
@@ -32,6 +32,13 @@ describe('packageRequirementsSchema', () => {
);
});
it('rejects duplicate tag ids', () => {
const tag = (id: string) => ({ id, name: 'production', usedByWorkflows: ['wf-1'] });
const requirements = { tags: [tag('tag-1'), tag('tag-1')] };
expect(() => packageRequirementsSchema.parse(requirements)).toThrow(/Duplicate tag id: tag-1/);
});
it('rejects duplicate variable names', () => {
const requirements = { variables: [variable('API_URL'), variable('API_URL')] };
@@ -41,6 +41,7 @@ export const packageManifestSchema = z
credentials: z.array(manifestEntrySchema).optional(),
dataTables: z.array(manifestEntrySchema).optional(),
variables: z.array(manifestEntrySchema).optional(),
tags: z.array(manifestEntrySchema).optional(),
requirements: packageRequirementsSchema.optional(),
})
.superRefine((manifest, ctx) => {
@@ -50,6 +51,7 @@ export const packageManifestSchema = z
assertNoDuplicateIds(manifest.credentials, 'credential', ctx);
assertNoDuplicateIds(manifest.dataTables, 'data table', ctx);
assertNoDuplicateIds(manifest.variables, 'variable', ctx);
assertNoDuplicateIds(manifest.tags, 'tag', ctx);
});
export type ManifestEntry = z.infer<typeof manifestEntrySchema>;
@@ -19,6 +19,12 @@ export const packageWorkflowRequirementSchema = z.object({
usedByWorkflows: z.array(z.string().min(1)).min(1),
});
export const packageTagRequirementSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
usedByWorkflows: z.array(z.string().min(1)).min(1),
});
// Node types used by the packaged workflows, folded into unique
// `(type, typeVersion)` pairs. Informational/derived only: import re-derives
// node type usage from workflow content and never trusts this section.
@@ -84,6 +90,10 @@ export const packageRequirementsSchema = z.object({
.superRefine((variables, ctx) =>
assertNoDuplicateKey(variables, ({ name }) => name, 'variable name', ctx),
),
tags: z
.array(packageTagRequirementSchema)
.optional()
.superRefine((tags, ctx) => assertNoDuplicateKey(tags, ({ id }) => id, 'tag id', ctx)),
nodeTypes: z
.array(packageNodeTypeRequirementSchema)
.optional()
@@ -100,6 +110,7 @@ export const packageRequirementsSchema = z.object({
export type PackageCredentialRequirement = z.infer<typeof packageCredentialRequirementSchema>;
export type PackageDataTableRequirement = z.infer<typeof packageDataTableRequirementSchema>;
export type PackageWorkflowRequirement = z.infer<typeof packageWorkflowRequirementSchema>;
export type PackageTagRequirement = z.infer<typeof packageTagRequirementSchema>;
export type PackageVariableRequirement = z.infer<typeof packageVariableRequirementSchema>;
export type PackageNodeTypeRequirement = z.infer<typeof packageNodeTypeRequirementSchema>;
export type PackageRequirements = z.infer<typeof packageRequirementsSchema>;
@@ -0,0 +1,27 @@
import { serializedTagSchema } from '../tag.schema';
describe('serializedTagSchema', () => {
it('accepts an id/name pair', () => {
const tag = { id: 'tag-1', name: 'production' };
expect(() => serializedTagSchema.parse(tag)).not.toThrow();
});
it('rejects unknown keys such as timestamps', () => {
const tag = { id: 'tag-1', name: 'production', createdAt: '2026-01-01T00:00:00.000Z' };
expect(() => serializedTagSchema.parse(tag)).toThrow();
});
it('rejects an empty name', () => {
const tag = { id: 'tag-1', name: '' };
expect(() => serializedTagSchema.parse(tag)).toThrow();
});
it('accepts a supplementary-plane name', () => {
const tag = { id: 'tag-1', name: '😀'.repeat(13) };
expect(() => serializedTagSchema.parse(tag)).not.toThrow();
});
});
@@ -28,4 +28,18 @@ describe('serializedWorkflowSchema', () => {
it('rejects a non-finite node typeVersion (JSON `1e999` parses to Infinity)', () => {
expect(() => serializedWorkflowSchema.parse(workflow(Infinity))).toThrow();
});
it('accepts a non-empty tagIds array', () => {
expect(() =>
serializedWorkflowSchema.parse({ ...workflow(1), tagIds: ['tag-1', 'tag-2'] }),
).not.toThrow();
});
it('rejects an empty tagIds array', () => {
expect(() => serializedWorkflowSchema.parse({ ...workflow(1), tagIds: [] })).toThrow();
});
it('rejects an empty-string tag id', () => {
expect(() => serializedWorkflowSchema.parse({ ...workflow(1), tagIds: [''] })).toThrow();
});
});
@@ -0,0 +1,10 @@
import { z } from 'zod';
export const serializedTagSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
})
.strict();
export type SerializedTag = z.infer<typeof serializedTagSchema>;
@@ -51,6 +51,7 @@ export const serializedWorkflowSchema = z.object({
parentFolderId: z.string().nullable(),
isPublished: z.boolean(),
isArchived: z.boolean(),
tagIds: z.array(z.string().min(1)).min(1).optional(),
});
export type SerializedWorkflow = z.infer<typeof serializedWorkflowSchema>;
@@ -45,6 +45,7 @@ const EXPORT_COUNTS = {
credentials: 0,
dataTables: 0,
variables: 0,
tags: 0,
};
describe('n8n-packages handler', () => {
@@ -57,6 +58,7 @@ describe('n8n-packages handler', () => {
folderIds?: string[];
projectIds?: string[];
includeVariableValues?: boolean;
includeTags?: boolean;
missingWorkflowDependencyPolicy?: string;
},
apiKeyScopes?: string[],
@@ -248,6 +250,7 @@ describe('n8n-packages handler', () => {
projectIds: [],
includeVariableValues: true,
canExportVariableValues: false,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
});
@@ -272,6 +275,7 @@ describe('n8n-packages handler', () => {
projectIds: [],
includeVariableValues: false,
canExportVariableValues: false,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
});
@@ -370,6 +374,7 @@ describe('n8n-packages handler', () => {
projectIds: [],
includeVariableValues: true,
canExportVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/gzip');
@@ -414,6 +419,7 @@ describe('n8n-packages handler', () => {
projectIds: [],
includeVariableValues: true,
canExportVariableValues: false,
includeTags: true,
missingWorkflowDependencyPolicy: 'reference-only',
});
});
@@ -438,6 +444,7 @@ describe('n8n-packages handler', () => {
projectIds: ['project-1'],
includeVariableValues: true,
canExportVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
});
@@ -462,6 +469,7 @@ describe('n8n-packages handler', () => {
projectIds: [],
includeVariableValues: true,
canExportVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
});
@@ -486,6 +494,32 @@ describe('n8n-packages handler', () => {
projectIds: [],
includeVariableValues: false,
canExportVariableValues: false,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
});
});
it('forwards includeTags=false to the service', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest({ workflowIds: ['wf-1'], includeTags: false }, ['workflow:export']),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
user: { id: 'user-1' },
workflowIds: ['wf-1'],
folderIds: [],
projectIds: [],
includeVariableValues: true,
canExportVariableValues: false,
includeTags: false,
missingWorkflowDependencyPolicy: 'fail',
});
});
@@ -34,6 +34,7 @@ type ExportPackageRequest = AuthenticatedRequest<
folderIds?: string[];
projectIds?: string[];
includeVariableValues?: boolean;
includeTags?: boolean;
missingWorkflowDependencyPolicy?: 'fail' | 'reference-only' | 'include-in-package';
}
>;
@@ -147,6 +148,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = {
projectIds,
includeVariableValues,
canExportVariableValues: apiKeyScopes.includes('variable:list'),
includeTags: payload.data.includeTags,
missingWorkflowDependencyPolicy: payload.data.missingWorkflowDependencyPolicy,
});
@@ -41,6 +41,13 @@ properties:
bundled into the package. When `false`, variables still travel as
name/type files and are listed in the package requirements, but no
values travel with the package.
includeTags:
type: boolean
default: true
description: >-
Whether tags assigned to the exported workflows are bundled into the
package. When `false`, no tag files, tag references, or tag
requirements travel with the package.
missingWorkflowDependencyPolicy:
type: string
enum:
@@ -165,14 +165,16 @@ export class WorkflowFinderService {
workflowIds: string[],
user: User,
scopes: Scope[],
options: { includeParentFolder?: boolean } = {},
options: { includeParentFolder?: boolean; includeTags?: boolean } = {},
): Promise<WorkflowEntity[]> {
if (workflowIds.length === 0) return [];
const where = await this.findAllWhere(user, scopes);
const sharedWorkflows = await this.sharedWorkflowRepository.find({
where: { ...where, workflowId: In(workflowIds) },
relations: { workflow: { parentFolder: options.includeParentFolder } },
relations: {
workflow: { parentFolder: options.includeParentFolder, tags: options.includeTags },
},
});
// A workflow may appear via several share paths (project membership +
@@ -183,4 +183,17 @@ describe('POST /n8n-packages/export', () => {
expect(response.headers['content-type']).toContain('application/gzip');
expect(response.headers['content-disposition']).toContain('export.n8np');
});
// The OpenAPI request schema has `additionalProperties: false`, so acceptance
// proves `includeTags` is declared in exportPackageRequest.yml.
test('accepts includeTags=false through the OpenAPI request validator', async () => {
const project = await createTeamProject('Export project', owner);
const folder = await createFolder(project, { name: 'to_production' });
const response = await authOwnerAgent
.post('/n8n-packages/export')
.send({ folderIds: [folder.id], includeTags: false });
expect(response.statusCode).toBe(200);
});
});