feat(core): Add selective promote for Git-based promotions (#38105)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: kgrhartlage <kai.hartlage@n8n.io>
This commit is contained in:
Sandra Zollner 2026-09-14 13:14:28 +00:00 committed by GitHub
parent 684b930fa6
commit 7ec481ceaa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1138 additions and 169 deletions

View File

@ -47,6 +47,7 @@ import {
WorkflowVersionPolicy,
type ExportPackageEventCounts,
type ExportPackageRequest,
type ExportPackageDirectoryResult,
type ExportPackageResult,
type ExportPackageSummary,
type ImportPackageRequest,
@ -124,11 +125,11 @@ export class N8nPackagesService {
async exportPackageToDirectory(
request: ExportPackageRequest,
target: { targetDir: string },
): Promise<ExportPackageSummary> {
): Promise<ExportPackageDirectoryResult> {
const writer = new DirectoryPackageWriter(target.targetDir);
const result = await this.exportPackageToWriter(request, writer);
await writer.finalize();
return { counts: result.counts };
return { counts: result.counts, manifest: result.manifest };
}
async exportPackageToWriter(

View File

@ -22,6 +22,7 @@ import type {
WorkflowPublishingOutcome,
WorkflowPublishingPolicy,
} from './entities/workflow/workflow-publishing-policy.types';
import type { PackageManifest } from './spec/manifest.schema';
export type { CredentialResolution } from './entities/credential/credential.types';
export { WorkflowPublishingPolicy } from './entities/workflow/workflow-publishing-policy.types';
@ -417,12 +418,21 @@ export interface ExportPackageSummary {
/**
* Result of an export where the package itself is returned to the caller as an
* archive stream, on top of the summary. Contrast with a directory export, which
* writes to disk in place and only returns the {@link ExportPackageSummary}.
* writes to disk in place and returns {@link ExportPackageDirectoryResult}.
*/
export interface ExportPackageResult extends ExportPackageSummary {
stream: Readable;
}
/**
* Result of an export written to a directory. It carries the manifest the
* export built, so a caller that keeps working with the directory does not
* have to read `manifest.json` back.
*/
export interface ExportPackageDirectoryResult extends ExportPackageSummary {
manifest: PackageManifest;
}
/**
* The outcome for one package workflow, folding in what the publish phase decided for it. Import
* writes and publishes in two separate phases, but a consumer cannot act on that distinction, so

View File

@ -460,6 +460,43 @@ describe('PromotionsGitService (git operations)', () => {
expect(mockGit.push).toHaveBeenCalledWith('origin', 'main', ['-f']);
});
it('restores HEAD and the index when the push fails', async () => {
mockGit.revparse.mockResolvedValueOnce('before\n').mockResolvedValueOnce('after\n');
mockGit.push.mockRejectedValueOnce(new Error('push rejected'));
await expect(call({ rollbackOnFailure: true })).rejects.toThrow(BadRequestError);
expect(mockGit.revparse.mock.invocationCallOrder[0]).toBeLessThan(
mockGit.add.mock.invocationCallOrder[0],
);
expect(mockGit.raw).toHaveBeenCalledExactlyOnceWith(['reset', '--mixed', 'before']);
expect(mockGit.raw.mock.invocationCallOrder[0]).toBeGreaterThan(
mockGit.push.mock.invocationCallOrder[0],
);
});
it('keeps the push error when rollback fails', async () => {
const pushError = new ServiceUnavailableError('Push timed out');
mockGit.push.mockRejectedValueOnce(pushError);
mockGit.raw.mockRejectedValueOnce(new Error('reset failed'));
await expect(call({ rollbackOnFailure: true })).rejects.toBe(pushError);
expect(logger.warn.mock.calls).toContainEqual([
'Failed to restore the Git revision after a failed promotion',
{ configId, branchName: 'main' },
]);
});
it('keeps the new commit when the push succeeds', async () => {
mockGit.revparse.mockResolvedValueOnce('before\n').mockResolvedValueOnce('after\n');
await expect(call({ rollbackOnFailure: true })).resolves.toEqual({ commitSha: 'after' });
expect(mockGit.push).toHaveBeenCalledExactlyOnceWith('origin', 'main');
expect(mockGit.raw).not.toHaveBeenCalled();
});
it('redacts a push failure and keeps raw git output out of the log', async () => {
mockGit.push.mockRejectedValue(new Error('remote: rejected [non-fast-forward] secret-token'));

View File

@ -14,7 +14,7 @@ import { Container } from '@n8n/di';
import type { Cipher, InstanceSettings } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import assert from 'node:assert';
import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { simpleGit, type SimpleGit } from 'simple-git';
@ -55,6 +55,7 @@ import { PromotionProvidersService } from '../promotion-providers.service';
import { PromotionWorkingDirectoryService } from '../promotion-working-directory.service';
import { PromotionsGitService } from '../promotions-git.service';
import { PromotionsService } from '../promotions.service';
import { WorkingCopyUpdater } from '../working-copy-updater';
type TestRemote = {
bareDir: string;
@ -133,9 +134,11 @@ beforeEach(async () => {
cipher.encryptV2.mockImplementation(async (value) => String(value));
const logger = mockLogger();
const gitService = new PromotionsGitService(logger);
workingDirectory = new PromotionWorkingDirectoryService(
mock<InstanceSettings>({ n8nFolder: path.join(testRoot, 'instance') }),
);
const instanceSettings = mock<InstanceSettings>({
n8nFolder: path.join(testRoot, 'instance'),
instanceId: 'inst-test',
});
workingDirectory = new PromotionWorkingDirectoryService(instanceSettings);
service = new PromotionsService(
new PromotionConfigResolver(
configRepository,
@ -145,6 +148,7 @@ beforeEach(async () => {
),
new PromotionProvidersService(providerRepository, connectionRepository, gitService, cipher),
workingDirectory,
new WorkingCopyUpdater(instanceSettings, logger),
gitService,
projectRepository,
projectService,
@ -229,6 +233,51 @@ async function createInstanceConnection(
return connection;
}
async function inspectBranch(
bareDir: string,
branch = 'main',
): Promise<{ git: SimpleGit; dir: string }> {
const inspectionDir = path.join(testRoot, `inspection-${Date.now()}`);
await simpleGit().clone(bareDir, inspectionDir, ['--branch', branch, '--single-branch']);
return { git: simpleGit(inspectionDir), dir: inspectionDir };
}
async function readBranchEntities(
inspectionDir: string,
fileName: 'workflow.json' | 'folder.json' | 'project.json',
): Promise<Array<{ id: string; name: string; target: string }>> {
const exportRoot = path.join(inspectionDir, 'n8n-export');
const found: Array<{ id: string; name: string; target: string }> = [];
const walk = async (dir: string): Promise<void> => {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
continue;
}
if (entry.name !== fileName) continue;
const parsed = jsonParse<{ id: string; name: string }>(await readFile(fullPath, 'utf-8'));
found.push({
id: parsed.id,
name: parsed.name,
target: path.relative(exportRoot, path.dirname(fullPath)).split(path.sep).join('/'),
});
}
};
await walk(exportRoot);
return found;
}
async function setupProjectWithWorkflows(projectName: string, workflowNames: string[]) {
const project = await createTeamProject(projectName, owner);
const workflows = [];
for (const name of workflowNames) {
workflows.push(await createWorkflow({ name, nodes: [], connections: {} }, project));
}
return { project, workflows };
}
async function writeRemoteFile(remote: TestRemote, relativePath: string, content: string) {
const filePath = path.join(remote.workingDir, relativePath);
await mkdir(path.dirname(filePath), { recursive: true });
@ -615,6 +664,311 @@ describe('Promote and Apply', () => {
});
});
describe('Promote a selection', () => {
it('refuses a selection when the branch has no package yet', async () => {
const remote = await createRemote();
const connection = await createInstanceConnection(remote.bareDir);
await service.clone(connection.id, 'promote');
const { project, workflows } = await setupProjectWithWorkflows('Orders', ['w1']);
await expect(
service.promoteSelection(
connection.id,
owner,
{ canExportVariableValues: true, commitMessage: 'Add w1' },
{ projectId: project.id, workflowIds: [workflows[0].id], deletedWorkflowIds: [] },
),
).rejects.toThrow('Promote the instance first');
});
it('adds only the selected workflow, leaving existing workflows untouched', async () => {
const remote = await createRemote();
const connection = await createInstanceConnection(remote.bareDir);
await service.clone(connection.id, 'promote');
const { project, workflows } = await setupProjectWithWorkflows('Orders', ['w1', 'w2', 'w3']);
await service.promote(connection.id, owner, {
canExportVariableValues: true,
commitMessage: 'Full promote',
});
const w4 = await createWorkflow({ name: 'w4', nodes: [], connections: {} }, project);
const result = await service.promoteSelection(
connection.id,
owner,
{ canExportVariableValues: true, commitMessage: 'Add w4' },
{ projectId: project.id, workflowIds: [w4.id], deletedWorkflowIds: [] },
);
const { dir } = await inspectBranch(remote.bareDir);
const onBranch = await readBranchEntities(dir, 'workflow.json');
const workflowIds = onBranch.map((w) => w.id);
expect(onBranch).toHaveLength(4);
expect(workflowIds).toContain(w4.id);
for (const w of workflows) {
expect(workflowIds).toContain(w.id);
}
expect(result.counts.workflows).toBe(1);
});
it('updates only the selected workflow, leaving others untouched', async () => {
const remote = await createRemote();
const connection = await createInstanceConnection(remote.bareDir);
await service.clone(connection.id, 'promote');
const { project, workflows } = await setupProjectWithWorkflows('Orders', ['w1', 'w2', 'w3']);
await service.promote(connection.id, owner, {
canExportVariableValues: true,
commitMessage: 'Full promote',
});
const before = await inspectBranch(remote.bareDir);
const w1Target = (await readBranchEntities(before.dir, 'workflow.json')).find(
(w) => w.id === workflows[0].id,
)!.target;
const w1FileBefore = await readFile(
path.join(before.dir, 'n8n-export', w1Target, 'workflow.json'),
'utf-8',
);
const workflowRepository = Container.get(WorkflowRepository);
await workflowRepository.update(workflows[0].id, {
name: 'w1-changed-but-not-selected',
nodes: [
{
id: 'n1',
name: 'NoOp',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
});
await workflowRepository.update(workflows[1].id, { name: 'w2-updated' });
await service.promoteSelection(
connection.id,
owner,
{ canExportVariableValues: true, commitMessage: 'Update w2' },
{
projectId: project.id,
workflowIds: [workflows[1].id],
deletedWorkflowIds: [],
},
);
const { dir } = await inspectBranch(remote.bareDir);
const onBranch = await readBranchEntities(dir, 'workflow.json');
expect(onBranch).toHaveLength(3);
expect(onBranch.find((w) => w.id === workflows[1].id)!.name).toBe('w2-updated');
const w1Entry = onBranch.find((w) => w.id === workflows[0].id)!;
expect(w1Entry).toEqual({ id: workflows[0].id, name: 'w1', target: w1Target });
expect(await readFile(path.join(dir, 'n8n-export', w1Target, 'workflow.json'), 'utf-8')).toBe(
w1FileBefore,
);
});
it('deletes the selected workflow from the branch', async () => {
const remote = await createRemote();
const connection = await createInstanceConnection(remote.bareDir);
await service.clone(connection.id, 'promote');
const { project, workflows } = await setupProjectWithWorkflows('Orders', ['w1', 'w2', 'w3']);
await service.promote(connection.id, owner, {
canExportVariableValues: true,
commitMessage: 'Full promote',
});
await service.promoteSelection(
connection.id,
owner,
{ canExportVariableValues: true, commitMessage: 'Delete w3' },
{
projectId: project.id,
workflowIds: [],
deletedWorkflowIds: [workflows[2].id],
},
);
const { dir } = await inspectBranch(remote.bareDir);
const workflowIds = (await readBranchEntities(dir, 'workflow.json')).map((w) => w.id);
expect(workflowIds).toHaveLength(2);
expect(workflowIds).not.toContain(workflows[2].id);
expect(workflowIds).toContain(workflows[0].id);
expect(workflowIds).toContain(workflows[1].id);
});
it('handles add and delete in the same promote atomically', async () => {
const remote = await createRemote();
const connection = await createInstanceConnection(remote.bareDir);
await service.clone(connection.id, 'promote');
const { project, workflows } = await setupProjectWithWorkflows('Orders', ['w1', 'w2', 'w3']);
await service.promote(connection.id, owner, {
canExportVariableValues: true,
commitMessage: 'Full promote',
});
const headAfterFull = (
await (await inspectBranch(remote.bareDir)).git.revparse(['HEAD'])
).trim();
const w4 = await createWorkflow({ name: 'w4', nodes: [], connections: {} }, project);
await service.promoteSelection(
connection.id,
owner,
{ canExportVariableValues: true, commitMessage: 'Add w4, delete w3' },
{
projectId: project.id,
workflowIds: [w4.id],
deletedWorkflowIds: [workflows[2].id],
},
);
const after = await inspectBranch(remote.bareDir);
const workflowIds = (await readBranchEntities(after.dir, 'workflow.json')).map((w) => w.id);
const commits = (await after.git.raw(['rev-list', '--count', `${headAfterFull}..HEAD`])).trim();
expect(commits).toBe('1');
expect(workflowIds).toHaveLength(3);
expect(workflowIds).toContain(w4.id);
expect(workflowIds).not.toContain(workflows[2].id);
});
it('preserves workflows from other projects during a selective promote', async () => {
const remote = await createRemote();
const connection = await createInstanceConnection(remote.bareDir);
await service.clone(connection.id, 'promote');
const { project: p1, workflows: p1Workflows } = await setupProjectWithWorkflows('Orders', [
'p1-w1',
]);
const { workflows: p2Workflows } = await setupProjectWithWorkflows('Billing', ['p2-w1']);
await service.promote(connection.id, owner, {
canExportVariableValues: true,
commitMessage: 'Full promote',
});
const p1w2 = await createWorkflow({ name: 'p1-w2', nodes: [], connections: {} }, p1);
await service.promoteSelection(
connection.id,
owner,
{ canExportVariableValues: true, commitMessage: 'Add p1-w2' },
{ projectId: p1.id, workflowIds: [p1w2.id], deletedWorkflowIds: [] },
);
const workflowIds = (
await readBranchEntities((await inspectBranch(remote.bareDir)).dir, 'workflow.json')
).map((w) => w.id);
expect(workflowIds).toContain(p1Workflows[0].id);
expect(workflowIds).toContain(p1w2.id);
expect(workflowIds).toContain(p2Workflows[0].id);
});
it('moves a renamed workflow to its new directory and removes the old one', async () => {
const remote = await createRemote();
const connection = await createInstanceConnection(remote.bareDir);
await service.clone(connection.id, 'promote');
const { project, workflows } = await setupProjectWithWorkflows('Orders', ['w1', 'w2']);
await service.promote(connection.id, owner, {
canExportVariableValues: true,
commitMessage: 'Full promote',
});
const oldTarget = (
await readBranchEntities((await inspectBranch(remote.bareDir)).dir, 'workflow.json')
).find((w) => w.id === workflows[1].id)!.target;
await Container.get(WorkflowRepository).update(workflows[1].id, { name: 'Renamed' });
await service.promoteSelection(
connection.id,
owner,
{ canExportVariableValues: true, commitMessage: 'Rename w2' },
{ projectId: project.id, workflowIds: [workflows[1].id], deletedWorkflowIds: [] },
);
const { dir } = await inspectBranch(remote.bareDir);
const onBranch = await readBranchEntities(dir, 'workflow.json');
const renamed = onBranch.find((w) => w.id === workflows[1].id)!;
expect(renamed.name).toBe('Renamed');
expect(renamed.target).not.toBe(oldTarget);
expect(renamed.target).toMatch(new RegExp(`/workflows/renamed-${workflows[1].id}$`));
await expect(
stat(path.join(dir, 'n8n-export', renamed.target, 'workflow.json')),
).resolves.toBeDefined();
await expect(stat(path.join(dir, 'n8n-export', oldTarget))).rejects.toThrow();
expect(onBranch).toHaveLength(2);
});
it('leaves a renamed folder alone, so unselected workflows keep their place', async () => {
const remote = await createRemote();
const connection = await createInstanceConnection(remote.bareDir);
await service.clone(connection.id, 'promote');
await service.clone(connection.id, 'apply');
const project = await createTeamProject('Orders', owner);
const folder = await createFolder(project, { name: 'Sales' });
const selected = await createWorkflow(
{ name: 'Selected', nodes: [], connections: {}, parentFolder: folder },
project,
);
const unselected = await createWorkflow(
{ name: 'Unselected', nodes: [], connections: {}, parentFolder: folder },
project,
);
await service.promote(connection.id, owner, {
canExportVariableValues: true,
commitMessage: 'Full promote',
});
const folderBefore = (
await readBranchEntities((await inspectBranch(remote.bareDir)).dir, 'folder.json')
).find((f) => f.id === folder.id)!;
const folderRepository = Container.get(FolderRepository);
await folderRepository.update(folder.id, { name: 'Revenue' });
await service.promoteSelection(
connection.id,
owner,
{ canExportVariableValues: true, commitMessage: 'Rename folder, promote one workflow' },
{ projectId: project.id, workflowIds: [selected.id], deletedWorkflowIds: [] },
);
const { dir } = await inspectBranch(remote.bareDir);
const folders = await readBranchEntities(dir, 'folder.json');
const workflows = await readBranchEntities(dir, 'workflow.json');
const folderEntry = folders.find((f) => f.id === folder.id)!;
const selectedEntry = workflows.find((w) => w.id === selected.id)!;
const unselectedEntry = workflows.find((w) => w.id === unselected.id)!;
expect(folderEntry).toMatchObject({ name: 'Sales', target: folderBefore.target });
expect(selectedEntry.target).toBe(`${folderEntry.target}/workflows/selected-${selected.id}`);
expect(unselectedEntry.target).toBe(
`${folderEntry.target}/workflows/unselected-${unselected.id}`,
);
await expect(
stat(path.join(dir, 'n8n-export', unselectedEntry.target, 'workflow.json')),
).resolves.toBeDefined();
const renamedTarget = folderEntry.target.replace(/[^/]+$/, `revenue-${folder.id}`);
await expect(stat(path.join(dir, 'n8n-export', renamedTarget))).rejects.toThrow();
const workflowRepository = Container.get(WorkflowRepository);
await workflowRepository.delete(unselected.id);
await service.apply(connection.id, owner);
expect(
await workflowRepository.findOne({
where: { id: unselected.id },
relations: ['parentFolder'],
}),
).toMatchObject({ name: 'Unselected', parentFolder: { id: folder.id } });
});
});
describe('Promotion base branch listing', () => {
let project: Project;

View File

@ -7,11 +7,14 @@ import path from 'node:path';
import { mock } from 'vitest-mock-extended';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error';
import type { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
import {
MissingWorkflowDependencyPolicy,
WorkflowVersionPolicy,
} from '@/modules/n8n-packages/n8n-packages.types';
import { packageManifestSchema } from '@/modules/n8n-packages/spec/manifest.schema';
import type { ProjectService } from '@/services/project.service.ee';
import type { PromotionConfigResolver } from '../promotion-config.resolver';
@ -20,10 +23,36 @@ import { PromotionWorkingDirectoryService } from '../promotion-working-directory
import type { PromotionsGitService } from '../promotions-git.service';
import { PromotionsService } from '../promotions.service';
import type { PromotionOperationInput, ResolvedPromotionConfig } from '../promotions.types';
import { WorkingCopyUpdater } from '../working-copy-updater';
vi.mock('node:fs/promises', async (importOriginal) => {
const fs = await importOriginal<typeof import('node:fs/promises')>();
return { ...fs, rm: vi.fn(fs.rm) };
});
const CONFIG_ID = 'cfg1';
const REMOTE_URL = 'git@github.com:o/r.git';
const emptyManifest = packageManifestSchema.parse({
packageFormatVersion: '1',
exportedAt: '2026-01-01T00:00:00.000Z',
sourceN8nVersion: '1.0.0',
sourceId: 'inst-1',
});
/** What an export writes for a workflow, so the branch can be read back. */
const branchWorkflowFile = (id: string, name: string) =>
JSON.stringify({
id,
name,
nodes: [],
connections: {},
versionId: `version-${id}`,
parentFolderId: null,
isPublished: false,
isArchived: false,
});
describe('PromotionsService', () => {
const resolver = mock<PromotionConfigResolver>();
const providersService = mock<PromotionProvidersService>();
@ -87,6 +116,10 @@ describe('PromotionsService', () => {
resolver,
providersService,
workingDirectory,
new WorkingCopyUpdater(
mock<InstanceSettings>({ n8nFolder, instanceId: 'inst-test' }),
logger,
),
gitService,
projectRepository,
projectService,
@ -100,6 +133,7 @@ describe('PromotionsService', () => {
});
afterEach(async () => {
vi.mocked(rm).mockReset();
await rm(n8nFolder, { recursive: true, force: true });
});
@ -181,6 +215,7 @@ describe('PromotionsService', () => {
await writeFile(path.join(targetDir, 'manifest.json'), '{"projects":[]}');
await writeFile(path.join(targetDir, 'projects', 'alpha', 'project.json'), '{}');
return {
manifest: emptyManifest,
counts: {
workflows: 0,
folders: 0,
@ -454,6 +489,407 @@ describe('PromotionsService', () => {
});
});
describe('promoteSelection', () => {
const actor = mock<User>({
id: 'actor',
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
});
const selection = { projectId: 'p1', workflowIds: ['w1', 'w2'], deletedWorkflowIds: [] };
let packageFolder: string;
let repositoryFolder: string;
const writeExportTree = async (base: string, files: Record<string, string>) => {
for (const [filePath, content] of Object.entries(files)) {
const fullPath = path.join(base, filePath);
await mkdir(path.dirname(fullPath), { recursive: true });
await writeFile(fullPath, content);
}
};
const buildManifest = (overrides: Record<string, unknown> = {}) =>
JSON.stringify(
{
packageFormatVersion: '1',
exportedAt: '2026-01-01T00:00:00.000Z',
sourceN8nVersion: '1.0.0',
sourceId: 'inst-1',
...overrides,
},
null,
'\t',
);
const mockExport = (files: Record<string, string>) => {
n8nPackagesService.exportPackageToDirectory.mockImplementation(
async (_request, { targetDir }) => {
await writeExportTree(targetDir, files);
const manifest = packageManifestSchema.parse(JSON.parse(files['manifest.json']));
return {
manifest,
counts: {
workflows: manifest.workflows?.length ?? 0,
folders: manifest.folders?.length ?? 0,
credentials: manifest.credentials?.length ?? 0,
dataTables: manifest.dataTables?.length ?? 0,
variables: manifest.variables?.length ?? 0,
tags: manifest.tags?.length ?? 0,
},
};
},
);
};
const readExported = async (relative: string) =>
await readFile(path.join(packageFolder, relative), 'utf-8');
const alpha = { id: 'p1', name: 'Alpha', target: 'projects/alpha' };
const wf = (id: string, name = id.toUpperCase()) => ({
id,
name,
target: `projects/alpha/workflows/${id}`,
});
const workflowFile = (id: string) =>
JSON.stringify({
id,
name: id.toUpperCase(),
nodes: [],
connections: {},
versionId: `version-${id}`,
parentFolderId: null,
isPublished: false,
isArchived: false,
});
beforeEach(async () => {
const input = promoteInput();
resolver.resolveForConnection.mockResolvedValue(input);
await markCloned(input, 'staging');
repositoryFolder = workingDirectory.paths(CONFIG_ID).repositoryFolder;
packageFolder = path.join(repositoryFolder, 'n8n-export');
projectRepository.findOneBy.mockResolvedValue({ id: 'p1', type: 'team' } as never);
gitService.commitAndPush.mockResolvedValue({ commitSha: 'selsha' });
});
it('asks the exporter for the selected workflows of the project with reference-only dependencies', async () => {
await writeExportTree(packageFolder, {
'manifest.json': buildManifest({ projects: [alpha] }),
'projects/alpha/project.json': JSON.stringify({ id: alpha.id, name: alpha.name }),
});
mockExport({
'manifest.json': buildManifest({ projects: [alpha], workflows: [wf('w1'), wf('w2')] }),
'projects/alpha/project.json': JSON.stringify({ id: alpha.id, name: alpha.name }),
'projects/alpha/workflows/w1/workflow.json': workflowFile('w1'),
'projects/alpha/workflows/w2/workflow.json': workflowFile('w2'),
});
await service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'm', canExportVariableValues: true },
selection,
);
expect(n8nPackagesService.exportPackageToDirectory).toHaveBeenCalledWith(
expect.objectContaining({
projectIds: ['p1'],
projectWorkflowIds: ['w1', 'w2'],
includeArchivedWorkflows: true,
canExportVariableValues: true,
missingWorkflowDependencyPolicy: MissingWorkflowDependencyPolicy.ReferenceOnly,
workflowVersionPolicy: WorkflowVersionPolicy.Latest,
}),
expect.any(Object),
);
});
it('refuses a selection when the branch has no package', async () => {
await mkdir(repositoryFolder, { recursive: true });
await expect(
service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'first selective', canExportVariableValues: true },
{ projectId: 'p1', workflowIds: ['w1'], deletedWorkflowIds: [] },
),
).rejects.toThrow('Promote the instance first');
expect(n8nPackagesService.exportPackageToDirectory).not.toHaveBeenCalled();
expect(gitService.commitAndPush).not.toHaveBeenCalled();
});
it('refuses a selection when the export does not match the selected workflows', async () => {
await writeExportTree(packageFolder, {
'manifest.json': buildManifest({ projects: [alpha], workflows: [wf('w1')] }),
'projects/alpha/project.json': JSON.stringify({ id: alpha.id, name: alpha.name }),
'projects/alpha/workflows/w1/workflow.json': workflowFile('w1'),
});
mockExport({
'manifest.json': buildManifest({ projects: [alpha], workflows: [wf('w1')] }),
'projects/alpha/project.json': JSON.stringify({ id: alpha.id, name: alpha.name }),
'projects/alpha/workflows/w1/workflow.json': workflowFile('w1'),
});
await expect(
service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'm', canExportVariableValues: true },
{ projectId: 'p1', workflowIds: ['w1', 'w2'], deletedWorkflowIds: [] },
),
).rejects.toThrow('The export does not match the selection (missing w2)');
expect(gitService.commitAndPush).not.toHaveBeenCalled();
expect(await readExported('projects/alpha/workflows/w1/workflow.json')).toBe(
workflowFile('w1'),
);
});
it('cleans up the staging folder even when the export fails', async () => {
await writeExportTree(packageFolder, {
'manifest.json': buildManifest({ projects: [alpha] }),
'projects/alpha/project.json': JSON.stringify({ id: alpha.id, name: alpha.name }),
});
n8nPackagesService.exportPackageToDirectory.mockRejectedValueOnce(
new BadRequestError('export failed'),
);
await expect(
service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'm', canExportVariableValues: true },
{ projectId: 'p1', workflowIds: ['w1'], deletedWorkflowIds: [] },
),
).rejects.toThrow(BadRequestError);
const stagingFolder = n8nPackagesService.exportPackageToDirectory.mock.calls[0][1].targetDir;
await expect(stat(stagingFolder)).rejects.toThrow();
});
it('refuses a deletion that is not on the branch before export', async () => {
await writeExportTree(packageFolder, {
'manifest.json': buildManifest({ projects: [alpha], workflows: [wf('w1')] }),
'projects/alpha/project.json': JSON.stringify(alpha),
'projects/alpha/workflows/w1/workflow.json': branchWorkflowFile('w1', 'W1'),
});
await expect(
service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'm', canExportVariableValues: true },
{ projectId: 'p1', workflowIds: [], deletedWorkflowIds: ['w-unknown'] },
),
).rejects.toThrow('Deleted workflows not found on the branch: w-unknown');
expect(n8nPackagesService.exportPackageToDirectory).not.toHaveBeenCalled();
expect(gitService.commitAndPush).not.toHaveBeenCalled();
expect(await readExported('projects/alpha/workflows/w1/workflow.json')).toBe(
branchWorkflowFile('w1', 'W1'),
);
});
it('refuses a workflow that moved to another project before export', async () => {
const beta = { id: 'p2', name: 'Beta', target: 'projects/beta' };
await writeExportTree(packageFolder, {
'manifest.json': buildManifest({
projects: [alpha, beta],
workflows: [{ id: 'w1', name: 'W1', target: 'projects/beta/workflows/w1' }],
}),
'projects/alpha/project.json': JSON.stringify(alpha),
'projects/beta/project.json': JSON.stringify(beta),
'projects/beta/workflows/w1/workflow.json': branchWorkflowFile('w1', 'W1'),
});
await expect(
service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'm', canExportVariableValues: true },
{ projectId: 'p1', workflowIds: ['w1'], deletedWorkflowIds: [] },
),
).rejects.toThrow('These workflows moved to another project: w1');
expect(n8nPackagesService.exportPackageToDirectory).not.toHaveBeenCalled();
expect(gitService.commitAndPush).not.toHaveBeenCalled();
expect(await readExported('projects/beta/workflows/w1/workflow.json')).toBe(
branchWorkflowFile('w1', 'W1'),
);
});
it('writes an import inventory that still lists unselected workflows', async () => {
await writeExportTree(packageFolder, {
'manifest.json': buildManifest({ projects: [alpha], workflows: [wf('w1')] }),
'projects/alpha/project.json': JSON.stringify({ id: alpha.id, name: alpha.name }),
'projects/alpha/workflows/w1/workflow.json': workflowFile('w1'),
});
mockExport({
'manifest.json': buildManifest({ projects: [alpha], workflows: [wf('w2')] }),
'projects/alpha/project.json': JSON.stringify({ id: alpha.id, name: alpha.name }),
'projects/alpha/workflows/w2/workflow.json': workflowFile('w2'),
});
await service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'add w2', canExportVariableValues: true },
{ projectId: 'p1', workflowIds: ['w2'], deletedWorkflowIds: [] },
);
const snapshot = JSON.parse(await readExported('manifest.json')) as {
workflows: Array<{ id: string }>;
};
expect(snapshot.workflows.map((w) => w.id).sort()).toEqual(['w1', 'w2']);
});
it('keeps the push result and removes staging when backup cleanup fails', async () => {
const files = {
'manifest.json': buildManifest({ projects: [alpha], workflows: [wf('w1')] }),
'projects/alpha/project.json': JSON.stringify(alpha),
'projects/alpha/workflows/w1/workflow.json': workflowFile('w1'),
};
await writeExportTree(packageFolder, files);
mockExport(files);
const cleanupError = new Error('Backup cleanup failed');
gitService.commitAndPush.mockImplementationOnce(async () => {
vi.mocked(rm).mockRejectedValueOnce(cleanupError);
return { commitSha: 'selsha' };
});
await expect(
service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'm', canExportVariableValues: true },
{ projectId: 'p1', workflowIds: ['w1'], deletedWorkflowIds: [] },
),
).resolves.toMatchObject({ git: { commitSha: 'selsha', branchName: 'staging' } });
const stagingFolder = n8nPackagesService.exportPackageToDirectory.mock.calls[0][1].targetDir;
await expect(stat(stagingFolder)).rejects.toMatchObject({ code: 'ENOENT' });
expect(logger.warn.mock.calls).toContainEqual([
'Failed to remove the selection backup',
{ prePushBackup: `${packageFolder}.pre-selection`, error: cleanupError },
]);
});
it('restores the package and keeps the push error when staging cleanup fails', async () => {
await writeExportTree(packageFolder, {
'manifest.json': buildManifest({ projects: [alpha], workflows: [wf('w1')] }),
'projects/alpha/project.json': JSON.stringify({ id: alpha.id, name: alpha.name }),
'projects/alpha/workflows/w1/workflow.json': workflowFile('w1'),
});
mockExport({
'manifest.json': buildManifest({ projects: [alpha], workflows: [wf('w2')] }),
'projects/alpha/project.json': JSON.stringify({ id: alpha.id, name: alpha.name }),
'projects/alpha/workflows/w2/workflow.json': workflowFile('w2'),
});
const pushError = new ServiceUnavailableError('timed out');
const cleanupError = new Error('Staging cleanup failed');
const { rm: realRm } =
await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
gitService.commitAndPush.mockImplementationOnce(async () => {
const stagingFolder =
n8nPackagesService.exportPackageToDirectory.mock.calls[0][1].targetDir;
vi.mocked(rm).mockImplementation(async (target, options) => {
if (target === stagingFolder) throw cleanupError;
await realRm(target, options);
});
throw pushError;
});
await expect(
service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'm', canExportVariableValues: true },
{ projectId: 'p1', workflowIds: ['w2'], deletedWorkflowIds: [] },
),
).rejects.toBe(pushError);
const stagingFolder = n8nPackagesService.exportPackageToDirectory.mock.calls[0][1].targetDir;
expect(logger.warn.mock.calls).toContainEqual([
'Failed to remove the selection staging folder',
{ stagingFolder, error: cleanupError },
]);
expect(gitService.commitAndPush.mock.calls).toContainEqual([
expect.objectContaining({ rollbackOnFailure: true }),
]);
expect(await readExported('projects/alpha/workflows/w1/workflow.json')).toBe(
workflowFile('w1'),
);
await expect(readExported('projects/alpha/workflows/w2/workflow.json')).rejects.toThrow();
});
it('refuses to promote a selection before the direction is cloned', async () => {
gitService.hasCheckout.mockResolvedValueOnce(false);
await expect(
service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'm', canExportVariableValues: true },
selection,
),
).rejects.toThrow('not cloned');
expect(n8nPackagesService.exportPackageToDirectory).not.toHaveBeenCalled();
expect(gitService.commitAndPush).not.toHaveBeenCalled();
});
it('refuses to promote a selection when the checkout was cloned from another branch', async () => {
resolver.resolveForConnection.mockResolvedValue(
operationInput({
direction: 'promote',
settings: {
schemaVersion: 1,
baseBranchName: 'release',
createBranchOnPromotion: false,
},
}),
);
await expect(
service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'm', canExportVariableValues: true },
selection,
),
).rejects.toThrow('not cloned');
expect(n8nPackagesService.exportPackageToDirectory).not.toHaveBeenCalled();
});
it('refuses a project-scope connection', async () => {
resolver.resolveForConnection.mockResolvedValue(
promoteInput({ connectionScope: 'projects' }),
);
await expect(
service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'm', canExportVariableValues: true },
selection,
),
).rejects.toThrow('instance connection');
expect(n8nPackagesService.exportPackageToDirectory).not.toHaveBeenCalled();
});
it('refuses a missing project', async () => {
projectRepository.findOneBy.mockResolvedValue(null);
await expect(
service.promoteSelection(
'conn1',
actor,
{ commitMessage: 'm', canExportVariableValues: true },
selection,
),
).rejects.toThrow(NotFoundError);
expect(n8nPackagesService.exportPackageToDirectory).not.toHaveBeenCalled();
});
});
describe('apply', () => {
const actor = mock<User>({ id: 'actor', role: { slug: 'global:owner' } });

View File

@ -119,6 +119,11 @@ describe('WorkingCopyUpdater', () => {
const readWrittenManifest = async () =>
packageManifestSchema.parse(JSON.parse(await readExported('manifest.json')));
const overlaySelection = async (staging: PackageManifest, sel: SelectivePushOptions) => {
const scanned = await updater.assertSelectionFitsBranch(exportFolder, sel);
return await updater.applySelection(exportFolder, stagingFolder, staging, sel, scanned);
};
/** Write the branch and the staging export, then apply the selection. */
const apply = async (
branch: Side,
@ -133,12 +138,7 @@ describe('WorkingCopyUpdater', () => {
'manifest.json': manifestFile(staging.manifest),
...staging.files,
});
return await updater.applySelection(
exportFolder,
stagingFolder,
staging.manifest,
selection(overrides),
);
return await overlaySelection(staging.manifest, selection(overrides));
};
beforeEach(async () => {
@ -177,6 +177,35 @@ describe('WorkingCopyUpdater', () => {
});
});
describe('assertStagingMatchesSelection', () => {
it('rejects selected workflows the export did not write', () => {
expect(() =>
updater.assertStagingMatchesSelection(
makeManifest({ workflows: [wf('w1')] }),
selection({ workflowIds: ['w1', 'w2'] }),
),
).toThrow('The export does not match the selection (missing w2)');
});
it('rejects extra workflows the export wrote', () => {
expect(() =>
updater.assertStagingMatchesSelection(
makeManifest({ workflows: [wf('w1'), wf('w2')] }),
selection({ workflowIds: ['w1'] }),
),
).toThrow('The export does not match the selection (extra w2)');
});
it('accepts a delete-only selection when the export has no workflows', () => {
expect(() =>
updater.assertStagingMatchesSelection(
makeManifest({ projects: [alpha] }),
selection({ deletedWorkflowIds: ['w1'] }),
),
).not.toThrow();
});
});
describe('readBranchLayout', () => {
it('takes projects, folders and workflows from their json files', async () => {
await writeTree(exportFolder, {
@ -342,12 +371,7 @@ describe('WorkingCopyUpdater', () => {
await writeTree(stagingFolder, { 'manifest.json': manifestFile(staging) });
await expect(
updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ deletedWorkflowIds: ['w-other'] }),
),
overlaySelection(staging, selection({ deletedWorkflowIds: ['w-other'] })),
).rejects.toThrow('Deleted workflows do not belong to the selected project: w-other');
expect(await readExported('projects/beta/workflows/w-other/workflow.json')).toBe(
@ -367,12 +391,7 @@ describe('WorkingCopyUpdater', () => {
await writeTree(stagingFolder, { 'manifest.json': manifestFile(staging) });
await expect(
updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ deletedWorkflowIds: ['w1'] }),
),
overlaySelection(staging, selection({ deletedWorkflowIds: ['w1'] })),
).rejects.toThrow(/two workflows with id "w1"/);
expect(await readExported('projects/alpha/workflows/w1/workflow.json')).toBe(
@ -404,12 +423,7 @@ describe('WorkingCopyUpdater', () => {
});
await expect(
updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w-moved'] }),
),
overlaySelection(staging, selection({ workflowIds: ['w-moved'] })),
).rejects.toThrow('These workflows moved to another project: w-moved');
expect(await readExported('projects/beta/workflows/w-moved/workflow.json')).toBe(
@ -660,12 +674,7 @@ describe('WorkingCopyUpdater', () => {
});
await expect(
updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w-new'] }),
),
overlaySelection(staging, selection({ workflowIds: ['w-new'] })),
).rejects.toThrow(/symbolic link/);
expect(await readExported('projects/alpha/workflows/w1/workflow.json')).toBe(
@ -684,12 +693,7 @@ describe('WorkingCopyUpdater', () => {
await writeTree(stagingFolder, { 'manifest.json': manifestFile(staging) });
await expect(
updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ deletedWorkflowIds: ['w1'] }),
),
overlaySelection(staging, selection({ deletedWorkflowIds: ['w1'] })),
).rejects.toThrow('would delete content the selection keeps');
expect(await readExported(`${shared}/workflow.json`)).toBe(workflowFile('w1'));
@ -708,12 +712,7 @@ describe('WorkingCopyUpdater', () => {
await writeTree(stagingFolder, { 'manifest.json': manifestFile(staging) });
await expect(
updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ deletedWorkflowIds: ['wbad'] }),
),
overlaySelection(staging, selection({ deletedWorkflowIds: ['wbad'] })),
).rejects.toThrow('would delete content the selection keeps');
expect(await readExported('projects/alpha/folders/sales/folder.json')).toBe(
@ -729,12 +728,7 @@ describe('WorkingCopyUpdater', () => {
'projects/alpha/workflows/w1/workflow.json': workflowFile('w1'),
});
await updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
);
await overlaySelection(staging, selection({ workflowIds: ['w1'] }));
expect(await readExported('projects/alpha/workflows/w1/workflow.json')).toBe(
workflowFile('w1'),
@ -760,6 +754,7 @@ describe('WorkingCopyUpdater', () => {
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
await updater.assertSelectionFitsBranch(linkedExport, selection({ workflowIds: ['w1'] })),
);
expect(
@ -782,12 +777,7 @@ describe('WorkingCopyUpdater', () => {
'projects/alpha/workflows/w1/workflow.json': workflowFile('w1'),
});
await updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
);
await overlaySelection(staging, selection({ workflowIds: ['w1'] }));
const tempCall = vi
.mocked(mkdtemp)
@ -815,12 +805,7 @@ describe('WorkingCopyUpdater', () => {
'projects/alpha/credentials/new-c1/credential.json': credentialFile('c1'),
});
await updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
);
await overlaySelection(staging, selection({ workflowIds: ['w1'] }));
expect(await readExported('projects/alpha/credentials/new-c1/credential.json')).toBe(
credentialFile('c1'),
@ -851,12 +836,7 @@ describe('WorkingCopyUpdater', () => {
'tags/new-t1/tag.json': tagFile('t1'),
});
await updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
);
await overlaySelection(staging, selection({ workflowIds: ['w1'] }));
await expectAbsent('projects/alpha/data-tables/old-d1');
await expectAbsent('tags/old-t1');
@ -885,12 +865,7 @@ describe('WorkingCopyUpdater', () => {
'projects/alpha/credentials/new-c1/credential.json': credentialFile('c1'),
});
await updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
);
await overlaySelection(staging, selection({ workflowIds: ['w1'] }));
await expectAbsent('projects/alpha/shared/credential.json');
expect(await readExported('projects/alpha/shared/tag.json')).toBe(tagFile('t1'));
@ -916,12 +891,7 @@ describe('WorkingCopyUpdater', () => {
'projects/alpha/credentials/new-c1/credential.json': credentialFile('c1'),
});
await updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
);
await overlaySelection(staging, selection({ workflowIds: ['w1'] }));
expect(await readExported('projects/alpha/credentials/new-c1/credential.json')).toBe(
credentialFile('c1'),
@ -941,12 +911,7 @@ describe('WorkingCopyUpdater', () => {
'projects/alpha/workflows/w1/workflow.json': workflowFile('w1'),
});
await updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
);
await overlaySelection(staging, selection({ workflowIds: ['w1'] }));
expect(await readExported('projects/alpha/credentials/leftover-c9/credential.json')).toBe(
credentialFile('c9'),
@ -1007,12 +972,7 @@ describe('WorkingCopyUpdater', () => {
'projects/alpha/variables/new-v1/variable.json': variableFile('v1', 'new'),
});
await updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
);
await overlaySelection(staging, selection({ workflowIds: ['w1'] }));
// Variables dedupe by id, so the stale directory is harmless; full push GCs it.
expect(await readExported('projects/alpha/variables/old-v1/variable.json')).toBe(
@ -1039,14 +999,9 @@ describe('WorkingCopyUpdater', () => {
});
vi.mocked(rename).mockRejectedValueOnce(new Error('EACCES'));
await expect(
updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
),
).rejects.toThrow('Failed to apply the selection to the branch');
await expect(overlaySelection(staging, selection({ workflowIds: ['w1'] }))).rejects.toThrow(
'Failed to apply the selection to the branch',
);
expect(await readExported('projects/alpha/workflows/w1/workflow.json')).toBe(
workflowFile('w1'),
@ -1073,14 +1028,9 @@ describe('WorkingCopyUpdater', () => {
.mockImplementationOnce(async (from, to) => await actualRename(from, to))
.mockRejectedValueOnce(new Error('EACCES'));
await expect(
updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
),
).rejects.toThrow('Failed to apply the selection to the branch');
await expect(overlaySelection(staging, selection({ workflowIds: ['w1'] }))).rejects.toThrow(
'Failed to apply the selection to the branch',
);
expect(await readExported('projects/alpha/workflows/w1/workflow.json')).toBe(
workflowFile('w1'),
@ -1096,14 +1046,9 @@ describe('WorkingCopyUpdater', () => {
});
vi.mocked(mkdtemp).mockRejectedValueOnce(new Error('ENOSPC'));
await expect(
updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
),
).rejects.toThrow('Failed to apply the selection to the branch');
await expect(overlaySelection(staging, selection({ workflowIds: ['w1'] }))).rejects.toThrow(
'Failed to apply the selection to the branch',
);
await expect(stat(exportFolder)).rejects.toThrow();
});
@ -1132,12 +1077,7 @@ describe('WorkingCopyUpdater', () => {
});
try {
await updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
);
await overlaySelection(staging, selection({ workflowIds: ['w1'] }));
expect(await readExported('projects/alpha/workflows/w1/workflow.json')).toBe(
workflowFile('w1', { v: 2 }),
@ -1162,12 +1102,7 @@ describe('WorkingCopyUpdater', () => {
'projects/alpha/workflows/w1/workflow.json': workflowFile('w1', { v: 2 }),
});
await updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
);
await overlaySelection(staging, selection({ workflowIds: ['w1'] }));
const aside = vi
.mocked(rename)
@ -1200,14 +1135,9 @@ describe('WorkingCopyUpdater', () => {
'projects/alpha/workflows/w1/workflow.json': workflowFile('w1'),
});
await expect(
updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ workflowIds: ['w1'] }),
),
).rejects.toThrow(/"projects\/alpha\/workflows" on the branch is a symbolic link/);
await expect(overlaySelection(staging, selection({ workflowIds: ['w1'] }))).rejects.toThrow(
/"projects\/alpha\/workflows" on the branch is a symbolic link/,
);
expect(await readdir(outside)).toEqual([]);
});
@ -1232,12 +1162,7 @@ describe('WorkingCopyUpdater', () => {
await writeTree(stagingFolder, { 'manifest.json': manifestFile(staging) });
await expect(
updater.applySelection(
exportFolder,
stagingFolder,
staging,
selection({ deletedWorkflowIds: ['w1'] }),
),
overlaySelection(staging, selection({ deletedWorkflowIds: ['w1'] })),
).rejects.toThrow(/"projects\/alpha\/workflows" on the branch is a symbolic link/);
expect(await readdir(path.join(outside, 'w1'))).toEqual(['workflow.json']);
});

View File

@ -218,6 +218,7 @@ export class PromotionsGitService {
force,
stagePathspec,
onCheckoutRestored,
rollbackOnFailure = false,
}: GitOperation & {
/** Push to this new branch instead of the configured base branch. */
targetBranchName?: string;
@ -227,6 +228,7 @@ export class PromotionsGitService {
stagePathspec: string;
/** Called only after the checkout returns to its base commit. */
onCheckoutRestored: () => Promise<void>;
rollbackOnFailure?: boolean;
}): Promise<{ commitSha: string }> {
try {
return await this.lockCheckout(
@ -252,18 +254,39 @@ export class PromotionsGitService {
});
}
// Scope staging to the package while including removed entities.
await git.add(['--all', '--', stagePathspec]);
await git.commit(commitMessage);
const commitSha = (await git.revparse(['HEAD'])).trim();
const previousHead = rollbackOnFailure
? (await git.revparse(['HEAD'])).trim()
: undefined;
try {
// Scope staging to the package while including removed entities.
await git.add(['--all', '--', stagePathspec]);
await git.commit(commitMessage);
const commitSha = (await git.revparse(['HEAD'])).trim();
if (force) {
await git.push('origin', branchName, ['-f']);
} else {
await git.push('origin', branchName);
if (force) {
await git.push('origin', branchName, ['-f']);
} else {
await git.push('origin', branchName);
}
return { commitSha };
} catch (error) {
if (previousHead) {
try {
// Restore Git state. The caller restores the package files.
await git.raw(['reset', '--mixed', previousHead]);
} catch {
this.logger.warn(
'Failed to restore the Git revision after a failed promotion',
{
configId,
branchName,
},
);
}
}
throw error;
}
return { commitSha };
},
),
);

View File

@ -8,11 +8,12 @@ import type {
import { Logger } from '@n8n/backend-common';
import { ProjectRepository, type User } from '@n8n/db';
import { Service } from '@n8n/di';
import { mkdir, mkdtemp, rename, rm, stat } from 'node:fs/promises';
import { cp, mkdir, mkdtemp, rename, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { UnexpectedError } from 'n8n-workflow';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
import {
DataTableMissingMode,
@ -33,6 +34,7 @@ import {
type ImportRequest,
type ImportResult,
} from '@/modules/n8n-packages/n8n-packages.types';
import { MANIFEST_FILE } from '@/modules/n8n-packages/spec/constants';
import { ProjectService } from '@/services/project.service.ee';
import {
@ -51,6 +53,7 @@ import {
repositoryUrl,
} from './promotions-git.utils';
import type { PromotionCacheDescriptor, PromotionOperationInput } from './promotions.types';
import { WorkingCopyUpdater, type SelectivePushOptions } from './working-copy-updater';
type ProjectReconciliationResult = { deletedProjectIds: string[] };
@ -84,6 +87,7 @@ export class PromotionsService {
private readonly resolver: PromotionConfigResolver,
private readonly providersService: PromotionProvidersService,
private readonly workingDirectory: PromotionWorkingDirectoryService,
private readonly workingCopy: WorkingCopyUpdater,
private readonly gitService: PromotionsGitService,
private readonly projectRepository: ProjectRepository,
private readonly projectService: ProjectService,
@ -230,6 +234,130 @@ export class PromotionsService {
}
}
/**
* Promotes selected workflows of one project and their dependencies. Unselected
* workflows stay as-is, and so do the projects and folders the branch already
* holds: a selection creates a container, never renames one, so nothing moves
* that the user did not select.
*/
async promoteSelection(
connectionId: string,
actor: User,
request: PromotePackageDto & { canExportVariableValues: boolean },
selection: SelectivePushOptions,
): Promise<PromotePackageResultDto> {
if (request.force) {
throw new BadRequestError(
"Selective promotion doesn't support force. Set force to false and try again.",
);
}
const input = await this.resolver.resolveForConnection(connectionId, 'promote');
this.assertInstanceScope(input, 'Promote');
this.workingCopy.validateSelection(selection);
await this.assertTeamProject(selection.projectId);
await this.assertCheckoutReady(input, 'promoting');
const branchName = checkoutBranchName(input.config);
const { repositoryFolder } = this.workingDirectory.paths(input.configId);
const packageFolder = path.join(repositoryFolder, PACKAGE_SUBFOLDER);
if (!(await this.hasExportedPackage(packageFolder))) {
throw new BadRequestError(
'The local checkout has no exported package. Promote the instance first, then promote a selection.',
);
}
const branch = await this.workingCopy.assertSelectionFitsBranch(packageFolder, selection);
const stagingFolder = await mkdtemp(path.join(repositoryFolder, `.${PACKAGE_SUBFOLDER}-`));
const prePushBackup = `${packageFolder}.pre-selection`;
let backedUp = false;
let keepPrePushBackup = false;
try {
await rm(prePushBackup, { recursive: true, force: true });
await cp(packageFolder, prePushBackup, { recursive: true, verbatimSymlinks: true });
backedUp = true;
// The exporter does the selecting: it writes the selected workflows and
// what they need, so nothing has to be filtered out afterwards.
const { manifest: staging, counts } = await this.n8nPackagesService.exportPackageToDirectory(
{
user: actor,
projectIds: [selection.projectId],
projectWorkflowIds: selection.workflowIds,
includeVariableValues: true,
canExportVariableValues: request.canExportVariableValues,
includeTags: true,
includeArchivedWorkflows: true,
// A sub-workflow nobody selected stays a reference. Failing here would
// block a promote whose sub-workflow the branch already holds.
missingWorkflowDependencyPolicy: MissingWorkflowDependencyPolicy.ReferenceOnly,
workflowVersionPolicy: WorkflowVersionPolicy.Latest,
},
{ targetDir: stagingFolder },
);
await this.workingCopy.applySelection(
packageFolder,
stagingFolder,
staging,
selection,
branch,
);
const { commitSha } = await this.gitService.commitAndPush({
remoteUrl: repositoryUrl(input),
credentials: await this.credentialsFor(input),
paths: this.workingDirectory.paths(input.configId),
branchName,
configId: input.configId,
author: this.commitAuthor(actor),
commitMessage: request.commitMessage,
force: false,
stagePathspec: PACKAGE_SUBFOLDER,
rollbackOnFailure: true,
onCheckoutRestored: async () =>
await this.workingDirectory.writeDescriptor(this.descriptorFor(input)),
});
return {
connectionId: input.connectionId,
configId: input.configId,
counts,
git: { commitSha, branchName },
};
} catch (error) {
if (backedUp) {
await rm(packageFolder, { recursive: true, force: true }).catch((restoreError: unknown) => {
this.logger.warn('Failed to remove the incomplete selection after a failed promote', {
packageFolder,
error: restoreError,
});
});
try {
await rename(prePushBackup, packageFolder);
} catch (restoreError: unknown) {
keepPrePushBackup = true;
this.logger.warn(
'Failed to restore the package from the pre-selection copy. The copy is at the backup path.',
{ packageFolder, backupFolder: prePushBackup, error: restoreError },
);
}
}
throw error;
} finally {
if (!keepPrePushBackup) {
await rm(prePushBackup, { recursive: true, force: true }).catch((error: unknown) => {
this.logger.warn('Failed to remove the selection backup', { prePushBackup, error });
});
}
await rm(stagingFolder, { recursive: true, force: true }).catch((error: unknown) => {
this.logger.warn('Failed to remove the selection staging folder', { stagingFolder, error });
});
}
}
/** Imports the package from the configured branch and replaces instance content. */
async apply(connectionId: string, actor: User): Promise<ApplyPackageResultDto> {
const input = await this.resolver.resolveForConnection(connectionId, 'apply');
@ -300,6 +428,22 @@ export class PromotionsService {
}
}
private async assertTeamProject(projectId: string) {
const project = await this.projectRepository.findOneBy({ id: projectId });
if (!project) throw new NotFoundError('Project not found');
if (project.type !== 'team') {
throw new BadRequestError('Only team projects can use a promotion connection');
}
}
private async hasExportedPackage(packageFolder: string): Promise<boolean> {
try {
return (await stat(path.join(packageFolder, MANIFEST_FILE))).isFile();
} catch {
return false;
}
}
/**
* A checkout counts as ready only when it exists and was cloned from the
* configuration we just resolved. Another process may have changed the remote or

View File

@ -54,6 +54,11 @@ interface DependencyRef {
target: string;
}
export type ScannedBranch = {
state: BranchLayout;
dependencies: DependencyRef[];
};
/**
* Applies a selective export to the exported working copy of a branch. It reads
* the branch, runs the guards, then overlays. The caller resolves the
@ -94,6 +99,24 @@ export class WorkingCopyUpdater {
}
}
/**
* The exporter must write exactly the selected workflows. Extra or missing
* ids mean the overlay would change the branch in a way the caller did not
* ask for, so stop before any file work.
*/
assertStagingMatchesSelection(staging: PackageManifest, selection: SelectivePushOptions): void {
const stagedIds = new Set((staging.workflows ?? []).map((workflow) => workflow.id));
const selectedIds = new Set(selection.workflowIds);
const missing = selection.workflowIds.filter((id) => !stagedIds.has(id));
const extra = [...stagedIds].filter((id) => !selectedIds.has(id));
if (missing.length === 0 && extra.length === 0) return;
const parts: string[] = [];
if (missing.length > 0) parts.push(`missing ${missing.join(', ')}`);
if (extra.length > 0) parts.push(`extra ${extra.join(', ')}`);
throw new BadRequestError(`The export does not match the selection (${parts.join('; ')})`);
}
/**
* What the branch holds, from `project.json`, `folder.json` and
* `workflow.json`. Placement and guards only need those collections.
@ -102,13 +125,11 @@ export class WorkingCopyUpdater {
return (await this.scanBranch(exportFolder)).state;
}
private async scanBranch(
exportFolder: string,
): Promise<{ state: BranchLayout; dependencies: DependencyRef[] }> {
private async scanBranch(exportFolder: string): Promise<ScannedBranch> {
const resolvedBase = await this.resolveContained(exportFolder, '.');
// A fresh branch holds no export yet, so a first push has nothing to read.
const rootInfo = await fs.stat(resolvedBase).catch(() => null);
if (rootInfo === null || !rootInfo.isDirectory()) return { state: {}, dependencies: [] };
if (!rootInfo?.isDirectory()) return { state: {}, dependencies: [] };
const collected: Required<BranchLayout> = { projects: [], folders: [], workflows: [] };
const dependencies: DependencyRef[] = [];
@ -232,6 +253,24 @@ export class WorkingCopyUpdater {
return { id: (parsed as { id: string }).id, name: (parsed as { name: string }).name, target };
}
async assertSelectionFitsBranch(
exportFolder: string,
selection: SelectivePushOptions,
): Promise<ScannedBranch> {
try {
const branch = await this.scanBranch(exportFolder);
this.assertDeletionsOnBranch(branch.state, selection);
this.assertNoCrossProjectMoves(branch.state, selection);
return branch;
} catch (error) {
if (error instanceof BadRequestError) throw error;
this.logger.error('Failed to scan the selective push working copy', { error });
throw new InternalServerError(
'Failed to apply the selection to the branch. Check the server logs for details.',
);
}
}
/**
* Every deleted workflow must be on the branch and belong to the selected
* project. Membership is judged by the project's directory on the branch,
@ -279,8 +318,9 @@ export class WorkingCopyUpdater {
}
/**
* Overlay the staging export onto `exportFolder`. Reads the branch, then
* runs the guards. File work runs on a copy, then the copy replaces the
* Overlay the staging export onto `exportFolder`. Pass `branch` from
* `assertSelectionFitsBranch` for the same folder and selection.
* File work runs on a copy, then the copy replaces the
* export, so a failed write leaves the working copy untouched. After overlay,
* write an import inventory of the remaining files. Delete that write with
* import-manifest-bridge when import walks entity files.
@ -290,8 +330,10 @@ export class WorkingCopyUpdater {
stagingFolder: string,
staging: PackageManifest,
selection: SelectivePushOptions,
branch: ScannedBranch,
): Promise<void> {
this.validateSelection(selection);
this.assertStagingMatchesSelection(staging, selection);
const parent = path.dirname(exportFolder);
// Keep the temp and backup dirs one level above the git clone (`parent`),
// so a crash never leaves them inside the working tree for a commit to pick
@ -303,9 +345,7 @@ export class WorkingCopyUpdater {
let createdExport = false;
try {
const { state: existing, dependencies } = await this.scanBranch(exportFolder);
this.assertDeletionsOnBranch(existing, selection);
this.assertNoCrossProjectMoves(existing, selection);
const { state: existing, dependencies } = branch;
const placement = containerPlacement(existing, staging);
const otherProjectTargets = (existing.projects ?? [])
@ -323,8 +363,7 @@ export class WorkingCopyUpdater {
// filesystem (e.g. a container mount) and are left alone.
await this.resolveContained(parent, path.basename(exportFolder));
// A first push meets a branch with no export yet; create it so the temp
// copy and the later swap have a directory to work with.
// Ensure the export directory exists before copying and replacing it.
// mkdir returns the first path it created, or undefined when it existed.
const firstCreated = await fs.mkdir(exportFolder, { recursive: true });
createdExport = firstCreated !== undefined;