From 7ec481ceaae14aa5f66d52afd17451719421f686 Mon Sep 17 00:00:00 2001 From: Sandra Zollner Date: Mon, 14 Sep 2026 13:14:28 +0000 Subject: [PATCH] feat(core): Add selective promote for Git-based promotions (#38105) Co-authored-by: Cursor Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: kgrhartlage --- .../n8n-packages/n8n-packages.service.ts | 5 +- .../n8n-packages/n8n-packages.types.ts | 12 +- .../__tests__/promotions-git.service.test.ts | 37 ++ .../promotions-package.integration.test.ts | 362 ++++++++++++++- .../__tests__/promotions.service.test.ts | 436 ++++++++++++++++++ .../__tests__/working-copy-updater.test.ts | 205 +++----- .../promotions.ee/promotions-git.service.ts | 43 +- .../promotions.ee/promotions.service.ts | 146 +++++- .../promotions.ee/working-copy-updater.ts | 61 ++- 9 files changed, 1138 insertions(+), 169 deletions(-) diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts index 613bea43050..38bacd4d4f1 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts @@ -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 { + ): Promise { 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( diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts index ba7da1d89ad..77ab45f9c0f 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts @@ -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 diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotions-git.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotions-git.service.test.ts index 5b823bd29bb..441968419c8 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotions-git.service.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotions-git.service.test.ts @@ -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')); diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts index 3bb6e1a9294..e46aacccf6b 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts @@ -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({ n8nFolder: path.join(testRoot, 'instance') }), - ); + const instanceSettings = mock({ + 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> { + const exportRoot = path.join(inspectionDir, 'n8n-export'); + const found: Array<{ id: string; name: string; target: string }> = []; + const walk = async (dir: string): Promise => { + 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; diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts index a056655193d..3963a40da7d 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts @@ -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(); + 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(); const providersService = mock(); @@ -87,6 +116,10 @@ describe('PromotionsService', () => { resolver, providersService, workingDirectory, + new WorkingCopyUpdater( + mock({ 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({ + 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) => { + 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 = {}) => + 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) => { + 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('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({ id: 'actor', role: { slug: 'global:owner' } }); diff --git a/packages/cli/src/modules/promotions.ee/__tests__/working-copy-updater.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/working-copy-updater.test.ts index 9817d726422..13bcc9f2e45 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/working-copy-updater.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/working-copy-updater.test.ts @@ -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']); }); diff --git a/packages/cli/src/modules/promotions.ee/promotions-git.service.ts b/packages/cli/src/modules/promotions.ee/promotions-git.service.ts index 2ff53861d81..fbc42ce2761 100644 --- a/packages/cli/src/modules/promotions.ee/promotions-git.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotions-git.service.ts @@ -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; + 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 }; }, ), ); diff --git a/packages/cli/src/modules/promotions.ee/promotions.service.ts b/packages/cli/src/modules/promotions.ee/promotions.service.ts index 3a72d9cad30..9465f3523f1 100644 --- a/packages/cli/src/modules/promotions.ee/promotions.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotions.service.ts @@ -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 { + 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 { 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 { + 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 diff --git a/packages/cli/src/modules/promotions.ee/working-copy-updater.ts b/packages/cli/src/modules/promotions.ee/working-copy-updater.ts index f36a161dc8b..4b650a2c4e2 100644 --- a/packages/cli/src/modules/promotions.ee/working-copy-updater.ts +++ b/packages/cli/src/modules/promotions.ee/working-copy-updater.ts @@ -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 { 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 = { 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 { + 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 { 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;