mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
684b930fa6
commit
7ec481ceaa
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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' } });
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user