feat: Add pinned Apply and Apply Continue to the promotion CLI commands (#39597)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nour Alhadi Mahmoud 2026-09-25 12:32:10 +00:00 committed by GitHub
parent 88c3962423
commit 6c57533958
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 678 additions and 37 deletions

View File

@ -61,7 +61,7 @@ n8n-cli --url=https://my-n8n.app.n8n.cloud --api-key=n8n_api_xxxxx workflow list
| `user` | `list`, `get` |
| `config` | `set-url`, `set-api-key`, `show` |
| `promotion-provider` | `list`, `get`, `create`, `update`, `delete` |
| `promotion-connection` | `list`, `get`, `create`, `update`, `delete`, `set-config`, `delete-config`, `clone`, `disconnect`, `promote`, `apply`, `list-projects`, `add-project`, `remove-project` |
| `promotion-connection` | `list`, `get`, `create`, `update`, `delete`, `set-config`, `delete-config`, `clone`, `disconnect`, `list-changes`, `promote`, `promote-selection`, `apply`, `apply-continue`, `list-projects`, `add-project`, `remove-project` |
| `source-control` | `pull` |
| `package` | `export`, `import` _(beta)_ |
| `skill` | `install` |

View File

@ -235,11 +235,18 @@ n8n-cli promotion-connection list-changes proj-abc promote
n8n-cli promotion-connection list-changes proj-abc apply
```
| Flag | Description |
|------|-------------|
| `--search` | List only the workflows whose name matches this text. |
| `--sort` | Sort by `name`, `updatedAt`, or `status`. The default is `name`. |
| `--order` | Sort order: `asc` or `desc`. The default is `asc`. |
For `promote` the rows are what a promotion would send to the branch, and the
API key needs `gitConnection:push`. For `apply` the rows are what applying the
branch would change on this instance, and the key needs `gitConnection:pull`.
`commitSha` is the commit the rows were read from. Clone the direction first.
Use the `id` of each row to build a selective promote.
`commitSha` is the commit the rows were read from. Table output shows the rows
only. Use `--json` to get the rows and `commitSha` together. Clone the direction
first. Use the `id` of each row to build a selective promote.
## `promotion-connection promote-selection`
@ -269,10 +276,68 @@ package. This overwrites the instance to match the branch.
```bash
n8n-cli promotion-connection apply conn-1
n8n-cli promotion-connection apply conn-1 \
--expected-config-id=cfg-2 --expected-branch=main --expected-commit-sha=<full sha>
```
| Flag | Description |
|------|-------------|
| `--expected-config-id` | ID of the apply configuration that you reviewed (`configs.apply.id`). |
| `--expected-branch` | Branch that you reviewed. |
| `--expected-commit-sha` | Full commit SHA that you reviewed (40 or 64 lowercase hex characters). |
Without the `--expected-*` flags, the command applies the branch tip. With them,
the command applies only the source that you reviewed. Pass all three flags or
none.
| Result | Exit code | What happened |
|--------|-----------|---------------|
| `applied` | `0` | The package was imported. JSON output includes `counts` and `warnings`. |
| `source-changed` | `3` | The configuration, branch, or commit is not the one you reviewed. Nothing was imported. Review the changes again. |
| `blocked` | `4` | Preflight found missing bindings, access requirements, or conflicts. Nothing was imported. The output shows the `apply-continue` command to run after you resolve them. |
With `--json`, the output is the full result for each status. For `blocked`, the
`preflight` object lists each missing project, missing binding, access
requirement, and conflict. Existing variable values on this instance are kept.
Clone the `apply` direction first. This command works on the `instance`
connection only.
connection only. The API key needs `gitConnection:pull`.
## `promotion-connection apply-continue`
Continue an Apply that was `blocked`, after you resolve the missing bindings, access requirements, or conflicts.
```bash
n8n-cli promotion-connection apply-continue conn-1 \
--expected-config-id=cfg-2 --expected-branch=main --expected-commit-sha=<full sha>
```
The three `--expected-*` flags are required. Use the `configId`,
`git.branchName`, and `git.commitSha` that the blocked Apply reported. The
command runs the preflight again. The results and exit codes are the same as
for `apply`: `blocked` again means that something still blocks, and
`source-changed` means that you must review again. The server keeps no session,
so run the command again when it exits `4`.
## Apply a reviewed commit
```bash
# 1. Review the changes. Take the commit SHA from the same response as the rows:
# a second request can read a newer commit.
n8n-cli promotion-connection list-changes proj-abc apply --json > reviewed-changes.json
jq '{commitSha, changes}' reviewed-changes.json
# 2. Read the apply configuration ID and branch of the connection.
n8n-cli promotion-connection get conn-1 --jq '.configs.apply.id'
n8n-cli promotion-connection get conn-1 --jq '.configs.apply.settings.branchName'
# 3. Apply that commit only.
n8n-cli promotion-connection apply conn-1 \
--expected-config-id=cfg-2 --expected-branch=main --expected-commit-sha=<sha from step 1>
# 4. On exit code 4, resolve the missing bindings, access requirements, or conflicts.
# Then run the apply-continue command that step 3 printed.
```
## `promotion-connection list-projects` / `add-project` / `remove-project`

View File

@ -58,6 +58,8 @@ n8n-cli execution list --workflow=1234 --status=error --limit=5 --format=json
| `0` | Success |
| `1` | General error |
| `2` | Authentication failure |
| `3` | Promotion Apply: the source changed since the review. Nothing was imported |
| `4` | Promotion Apply: preflight found missing bindings, access requirements, or conflicts. Nothing was imported |
AI agents can branch on `$?` for error handling.

View File

@ -216,6 +216,26 @@ n8n-cli promotion-connection promote <id> -m "Promote team projects"
n8n-cli promotion-connection apply <id>
```
Apply a reviewed commit only:
```bash
# Review the changes. Take the commit SHA from the same response as the rows:
# a second request can read a newer commit.
n8n-cli promotion-connection list-changes <projectId> apply --sort=updatedAt --order=desc --json > reviewed-changes.json
jq '{commitSha, changes}' reviewed-changes.json
# The config ID and branch come from the connection.
n8n-cli promotion-connection get <id> --jq '.configs.apply.id'
n8n-cli promotion-connection get <id> --jq '.configs.apply.settings.branchName'
n8n-cli promotion-connection apply <id> \
--expected-config-id=<configId> --expected-branch=<branch> --expected-commit-sha=<full sha>
# Exit 3: the source changed since the review. Review again.
# Exit 4: preflight found missing bindings, access requirements, or conflicts. Resolve them,
# then run the apply-continue command that apply printed:
n8n-cli promotion-connection apply-continue <id> \
--expected-config-id=<configId> --expected-branch=<branch> --expected-commit-sha=<full sha>
```
Connection JSON for step 2:
```json
@ -274,6 +294,12 @@ Key points:
- `createBranchOnPromotion` is always required in a promote configuration.
- `promote` and `apply` work on the `instance` connection only, and need their
direction cloned first. Cloning one direction does not make the other ready.
- `apply` and `apply-continue` import nothing on exit code `3` (`source-changed`)
or `4` (`blocked`). Check `$?`, or the `status` field in JSON output, before you
read `counts`. With `--json`, a `blocked` result lists what is missing under
`preflight`.
- The `--expected-*` flags go together: pass all three or none. The commit SHA
must be the full SHA.
- API key scopes for this group are named `gitConnection:*`. `promote` also needs
`variable:list` when the workflows reference variables.

View File

@ -78,6 +78,69 @@ describe('N8nClient packages', () => {
});
});
const expectedSource = {
configId: 'cfg-2',
branchName: 'release',
commitSha: 'a'.repeat(40),
};
it.each([
{
title: 'posts apply with no body when no expected source is given',
send: async () => await client.applyPackage('conn-1'),
path: 'apply',
body: undefined,
},
{
title: 'posts apply with the expected source in the body',
send: async () => await client.applyPackage('conn-1', expectedSource),
path: 'apply',
body: { expectedSource },
},
{
title: 'posts continue to apply/continue with the expected source in the body',
send: async () => await client.continueApplyPackage('conn-1', expectedSource),
path: 'apply/continue',
body: { expectedSource },
},
])('$title', async ({ send, path, body }) => {
fetchMock.mockResolvedValue(jsonResponse(200, { status: 'applied' }));
await send();
expect(requestLines(fetchMock)).toEqual([
`POST https://n8n.example.com/api/v1/promotions/connections/conn-1/${path}`,
]);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(typeof init.body === 'string' ? JSON.parse(init.body) : init.body).toEqual(body);
});
describe('list changes', () => {
beforeEach(() => {
fetchMock.mockResolvedValue(jsonResponse(200, { commitSha: null, changes: [] }));
});
it('sends no query parameters when no options are given', async () => {
await client.listProjectPromotionChanges('proj-1', 'apply');
expect(requestLines(fetchMock)).toEqual([
'GET https://n8n.example.com/api/v1/promotions/projects/proj-1/changes/apply',
]);
});
it('sends search, sort, and order as query parameters', async () => {
await client.listProjectPromotionChanges('proj-1', 'promote', {
search: 'checkout',
sort: 'updatedAt',
order: 'desc',
});
expect(requestLines(fetchMock)).toEqual([
'GET https://n8n.example.com/api/v1/promotions/projects/proj-1/changes/promote?search=checkout&sort=updatedAt&order=desc',
]);
});
});
it('clones and disconnects one direction of a connection', async () => {
const checkout = {
connectionId: 'conn-1',

View File

@ -143,7 +143,7 @@ const CASES: WiringCase[] = [
commandClass: PromotionConnectionListChanges,
args: { projectId: 'proj-1', direction: 'promote' },
method: 'listProjectPromotionChanges',
callArgs: ['proj-1', 'promote'],
callArgs: ['proj-1', 'promote', {}],
},
{
title: 'promotion-connection add-project',

View File

@ -8,6 +8,9 @@ interface ListChangesFlags {
format?: string;
json?: boolean;
jq?: string;
search?: string;
sort?: string;
order?: string;
}
/** The command methods we stub to isolate behaviour from oclif/networking. */
@ -51,7 +54,7 @@ function stubCommand(flags: ListChangesFlags) {
} as unknown as N8nClient);
const output = vi.spyOn(internals, 'output').mockImplementation(() => {});
return { command, output };
return { command, output, listProjectPromotionChanges };
}
describe('promotion-connection list-changes command', () => {
@ -59,6 +62,22 @@ describe('promotion-connection list-changes command', () => {
vi.restoreAllMocks();
});
it('passes the search, sort, and order flags to the change preview', async () => {
const { command, listProjectPromotionChanges } = stubCommand({
search: 'checkout',
sort: 'updatedAt',
order: 'desc',
});
await command.run();
expect(listProjectPromotionChanges).toHaveBeenCalledWith('proj-1', 'promote', {
search: 'checkout',
sort: 'updatedAt',
order: 'desc',
});
});
it('unwraps the changes array for table output so rows render individually', async () => {
const { command, output } = stubCommand({ format: 'table' });

View File

@ -3,6 +3,12 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import type { N8nClient } from '../client';
import PromotionConnectionApply from '../commands/promotion-connection/apply';
import PromotionConnectionApplyContinue from '../commands/promotion-connection/apply-continue';
import {
parseCommitSha,
parseNonEmpty,
toExpectedSource,
} from '../commands/promotion-connection/expected-source';
import PromotionConnectionPromote from '../commands/promotion-connection/promote';
import PromotionConnectionPromoteSelection from '../commands/promotion-connection/promote-selection';
@ -13,6 +19,12 @@ interface OperationInternals {
succeed: (message: string, ...rest: unknown[]) => void;
}
interface ApplyInternals extends OperationInternals {
output: (data: unknown, flags: unknown) => void;
logToStderr: (message: string) => void;
exit: (code: number) => never;
}
const PROMOTE_RESULT = {
connectionId: 'conn-1',
configId: 'cfg-1',
@ -20,15 +32,38 @@ const PROMOTE_RESULT = {
git: { commitSha: 'abc1234', branchName: 'main' },
};
const APPLY_RESULT = {
const SHA = 'd'.repeat(40);
const IDENTITY = {
connectionId: 'conn-1',
configId: 'cfg-2',
counts: {},
git: { commitSha: 'def5678', branchName: 'release' },
git: { commitSha: SHA, branchName: 'release' },
};
const APPLY_RESULT = { ...IDENTITY, status: 'applied', counts: {}, warnings: [] };
const SOURCE_CHANGED_RESULT = { ...IDENTITY, status: 'source-changed' };
const BLOCKED_RESULT = {
...IDENTITY,
status: 'blocked',
preflight: {
missingProjects: [{ id: 'p-1', name: 'Sales' }],
missingBindings: [
{ kind: 'credential', sourceId: 'c-1' },
{ kind: 'credential', sourceId: 'c-2' },
{ kind: 'variable', name: 'API_URL' },
],
accessRequirements: [],
conflicts: [{ kind: 'variable', code: 'missing-definition' }],
warnings: [],
},
};
const PINNED_FLAGS = {
expectedConfigId: 'cfg-2',
expectedBranch: 'release',
expectedCommitSha: SHA,
};
const EXPECTED_SOURCE = { configId: 'cfg-2', branchName: 'release', commitSha: SHA };
function stubCommand(
command: PromotionConnectionPromote | PromotionConnectionApply,
command: PromotionConnectionPromote | PromotionConnectionApply | PromotionConnectionApplyContinue,
flags: Record<string, unknown>,
client: Partial<N8nClient>,
) {
@ -42,6 +77,19 @@ function stubCommand(
return succeed;
}
function stubApplyCommand(
command: PromotionConnectionApply | PromotionConnectionApplyContinue,
flags: Record<string, unknown>,
client: Partial<N8nClient>,
) {
const succeed = stubCommand(command, flags, client);
const internals = command as unknown as ApplyInternals;
const output = vi.spyOn(internals, 'output').mockImplementation(() => {});
const logToStderr = vi.spyOn(internals, 'logToStderr').mockImplementation(() => {});
const exit = vi.spyOn(internals, 'exit').mockImplementation(() => undefined as never);
return { succeed, output, logToStderr, exit };
}
describe('promotion-connection promote command', () => {
afterEach(() => {
vi.restoreAllMocks();
@ -76,22 +124,177 @@ describe('promotion-connection apply command', () => {
vi.restoreAllMocks();
});
it('names the branch and commit it applied', async () => {
function runApply(result: unknown, flags: Record<string, unknown> = { format: 'table' }) {
const command = new PromotionConnectionApply([], {} as Config);
const applyPackage = vi.fn().mockResolvedValue(APPLY_RESULT);
const succeed = stubCommand(command, {}, {
const applyPackage = vi.fn().mockResolvedValue(result);
const stubs = stubApplyCommand(command, flags, {
applyPackage,
} as unknown as Partial<N8nClient>);
return { run: async () => await command.run(), applyPackage, ...stubs };
}
it('sends the reviewed source built from the three expected flags', async () => {
const { run, applyPackage } = runApply(APPLY_RESULT, { format: 'table', ...PINNED_FLAGS });
await run();
expect(applyPackage).toHaveBeenCalledWith('conn-1', EXPECTED_SOURCE);
});
it('names the branch and commit it applied, and exits through succeed', async () => {
const { run, succeed, exit } = runApply(APPLY_RESULT);
await run();
expect(succeed).toHaveBeenCalledWith(
`Applied release at commit ${SHA} to the instance.`,
expect.anything(),
APPLY_RESULT,
);
expect(exit).not.toHaveBeenCalled();
});
it('counts the warnings of a successful Apply in the Applied message', async () => {
const result = { ...APPLY_RESULT, warnings: [{}, {}] };
const { run, succeed } = runApply(result);
await run();
expect(succeed).toHaveBeenCalledWith(
`Applied release at commit ${SHA} to the instance with 2 warning(s).`,
expect.anything(),
result,
);
});
it('exits 3 without reporting success when the source changed since the review', async () => {
const { run, succeed, logToStderr, exit } = runApply(SOURCE_CHANGED_RESULT);
await run();
expect(succeed).not.toHaveBeenCalled();
expect(logToStderr).toHaveBeenCalledWith(expect.stringContaining('Nothing was imported.'));
expect(logToStderr).toHaveBeenCalledWith(
expect.stringContaining(`config cfg-2, branch release, commit ${SHA}`),
);
expect(exit).toHaveBeenCalledWith(3);
});
it('exits 4 with binding counts and a ready Continue command when bindings are missing', async () => {
const { run, succeed, logToStderr, exit } = runApply(BLOCKED_RESULT);
await run();
expect(succeed).not.toHaveBeenCalled();
const message = logToStderr.mock.calls[0][0];
expect(message).toContain('Missing projects: 1');
expect(message).toContain('Missing credentials: 2');
expect(message).toContain('Missing variables: 1');
expect(message).toContain('Access requirements: 0');
expect(message).toContain('Conflicts: 1');
expect(message).toContain(
`n8n-cli promotion-connection apply-continue conn-1 --expected-config-id=cfg-2 --expected-branch=release --expected-commit-sha=${SHA}`,
);
expect(exit).toHaveBeenCalledWith(4);
});
it('quotes source values in the Continue command so it is safe to paste', async () => {
const { run, logToStderr } = runApply({
...BLOCKED_RESULT,
git: { commitSha: SHA, branchName: "release;$(id)'s" },
});
await run();
expect(logToStderr.mock.calls[0][0]).toContain(
`--expected-branch='release;$(id)'\\''s' --expected-commit-sha=${SHA}`,
);
});
it.each([
{ result: APPLY_RESULT, code: 0 },
{ result: BLOCKED_RESULT, code: 4 },
])(
'sends the $result.status result through the jq-aware output in JSON mode',
async ({ result, code }) => {
const { run, succeed, output, logToStderr, exit } = runApply(result, { jq: '.status' });
await run();
expect(output).toHaveBeenCalledWith(result, expect.objectContaining({ jq: '.status' }));
expect(succeed).not.toHaveBeenCalled();
expect(logToStderr).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(code);
},
);
it('keeps the exit code when quiet', async () => {
const { run, logToStderr, exit } = runApply(SOURCE_CHANGED_RESULT, {
format: 'table',
quiet: true,
});
await run();
expect(logToStderr).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(3);
});
});
describe('promotion-connection apply-continue command', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('continues with the reviewed source and reports the result like Apply', async () => {
const command = new PromotionConnectionApplyContinue([], {} as Config);
const continueApplyPackage = vi.fn().mockResolvedValue(SOURCE_CHANGED_RESULT);
const { exit } = stubApplyCommand(command, { format: 'table', ...PINNED_FLAGS }, {
continueApplyPackage,
} as unknown as Partial<N8nClient>);
await command.run();
expect(applyPackage).toHaveBeenCalledWith('conn-1');
expect(succeed).toHaveBeenCalledWith(
'Applied release at commit def5678 to the instance.',
expect.anything(),
APPLY_RESULT,
expect(continueApplyPackage).toHaveBeenCalledWith('conn-1', EXPECTED_SOURCE);
expect(exit).toHaveBeenCalledWith(3);
});
});
describe('expected config ID and branch flags', () => {
it('reject a value that is empty after trimming', async () => {
await expect(parseNonEmpty('branch name')(' ')).rejects.toThrow(
'Expected a non-empty branch name',
);
});
it('keep a non-empty value unchanged', async () => {
await expect(parseNonEmpty('branch name')('release/1.0')).resolves.toBe('release/1.0');
});
});
describe('expected source from flags', () => {
it('pins nothing when no flag is given', () => {
expect(toExpectedSource({})).toBeUndefined();
});
it('keeps an empty value so the request fails instead of applying the branch tip', () => {
expect(
toExpectedSource({ expectedConfigId: 'cfg-2', expectedBranch: '', expectedCommitSha: SHA }),
).toEqual({ configId: 'cfg-2', branchName: '', commitSha: SHA });
});
});
describe('expected commit SHA flag', () => {
it.each(['a'.repeat(40), '0123456789abcdef'.repeat(4)])('accepts %s', async (sha) => {
await expect(parseCommitSha(sha)).resolves.toBe(sha);
});
it.each(['abc1234', 'A'.repeat(40), 'g'.repeat(40), 'a'.repeat(41)])(
'rejects %s',
async (sha) => {
await expect(parseCommitSha(sha)).rejects.toThrow('full lowercase commit SHA');
},
);
});
describe('promotion-connection promote-selection command', () => {

View File

@ -6,9 +6,11 @@ import { resolveConnection } from './config';
import { formatOutput, applyJqFilter, type OutputFormat, type OutputOptions } from './output';
/** Exit codes following the RFC spec. */
const EXIT_SUCCESS = 0;
export const EXIT_SUCCESS = 0;
const EXIT_ERROR = 1;
const EXIT_AUTH = 2;
export const EXIT_SOURCE_CHANGED = 3;
export const EXIT_BLOCKED = 4;
export abstract class BaseCommand extends Command {
static override baseFlags = {

View File

@ -108,14 +108,44 @@ export type PromotePackageResult = {
git: PromotionGitResult;
};
/** Outcome of applying a package to the instance. */
export type ApplyPackageResult = {
/** The reviewed source that Apply must still match. Any mismatch reports `source-changed`. */
export interface PromotionExpectedSource {
configId: string;
branchName: string;
commitSha: string;
}
/** References in a package that must be set up on this instance before Apply can import it. */
export interface PromotionBindingPreflight {
missingProjects: Array<Record<string, unknown>>;
missingBindings: Array<Record<string, unknown>>;
accessRequirements: Array<Record<string, unknown>>;
conflicts: Array<Record<string, unknown>>;
warnings: Array<Record<string, unknown>>;
}
type ApplyPackageIdentity = {
connectionId: string;
configId: string;
counts: ImportPackageCounts;
git: PromotionGitResult;
};
/** Outcome of applying a package to the instance. Only `applied` imported anything. */
export type ApplyPackageResult =
| (ApplyPackageIdentity & {
status: 'applied';
counts: ImportPackageCounts;
warnings: Array<Record<string, unknown>>;
})
| (ApplyPackageIdentity & { status: 'blocked'; preflight: PromotionBindingPreflight })
| (ApplyPackageIdentity & { status: 'source-changed' });
export interface PromotionChangesQuery {
search?: string;
sort?: 'name' | 'updatedAt' | 'status';
order?: 'asc' | 'desc';
}
/** One workflow that differs between a project and its configured branch. */
export interface PromotableResourceSummary {
id: string;
@ -391,13 +421,27 @@ export class N8nClient {
return await this.post<PromotePackageResult>(`/promotions/connections/${id}/promote`, body);
}
async applyPackage(id: string) {
return await this.post<ApplyPackageResult>(`/promotions/connections/${id}/apply`);
async applyPackage(id: string, expectedSource?: PromotionExpectedSource) {
return await this.post<ApplyPackageResult>(
`/promotions/connections/${id}/apply`,
expectedSource ? { expectedSource } : undefined,
);
}
async listProjectPromotionChanges(projectId: string, direction: PromotionDirection) {
async continueApplyPackage(id: string, expectedSource: PromotionExpectedSource) {
return await this.post<ApplyPackageResult>(`/promotions/connections/${id}/apply/continue`, {
expectedSource,
});
}
async listProjectPromotionChanges(
projectId: string,
direction: PromotionDirection,
query: PromotionChangesQuery = {},
) {
return await this.get<ProjectPromotionChanges>(
`/promotions/projects/${projectId}/changes/${direction}`,
{ ...query },
);
}

View File

@ -0,0 +1,82 @@
import { BaseCommand, EXIT_BLOCKED, EXIT_SOURCE_CHANGED, EXIT_SUCCESS } from '../../base-command';
import type { ApplyPackageResult, PromotionBindingPreflight } from '../../client';
type ReportFlags = { quiet?: boolean; format?: string; json?: boolean; jq?: string };
/** Shared result handling for Apply and Continue, which return the same statuses. */
export abstract class PromotionApplyCommand extends BaseCommand {
protected reportApplyResult(
result: ApplyPackageResult,
flags: ReportFlags,
connectionId: string,
): void {
// `succeed()` ignores --jq, so JSON output for every status goes through `output()`.
if (this.isJsonMode(flags)) {
this.output(result, flags);
this.exit(exitCodeFor(result));
return;
}
if (result.status === 'applied') {
const warnings =
result.warnings.length > 0 ? ` with ${result.warnings.length} warning(s)` : '';
this.succeed(
`Applied ${result.git.branchName} at commit ${result.git.commitSha} to the instance${warnings}.`,
flags,
result,
);
return;
}
if (!flags.quiet) {
this.logToStderr(
result.status === 'blocked'
? blockedMessage(result, result.preflight, connectionId)
: sourceChangedMessage(result),
);
}
this.exit(exitCodeFor(result));
}
}
function exitCodeFor(result: ApplyPackageResult): number {
if (result.status === 'applied') return EXIT_SUCCESS;
return result.status === 'blocked' ? EXIT_BLOCKED : EXIT_SOURCE_CHANGED;
}
function sourceChangedMessage(result: ApplyPackageResult): string {
return [
'The source changed since the review. Nothing was imported.',
`Current source: config ${result.configId}, branch ${result.git.branchName}, commit ${result.git.commitSha}.`,
'Review the changes again, then apply the new commit.',
].join('\n');
}
function blockedMessage(
result: ApplyPackageResult,
preflight: PromotionBindingPreflight,
connectionId: string,
): string {
const bindings = (kind: 'credential' | 'variable') =>
preflight.missingBindings.filter((binding) => binding.kind === kind).length;
return [
'Apply is blocked. Nothing was imported.',
` Missing projects: ${preflight.missingProjects.length}`,
` Missing credentials: ${bindings('credential')}`,
` Missing variables: ${bindings('variable')}`,
` Access requirements: ${preflight.accessRequirements.length}`,
` Conflicts: ${preflight.conflicts.length}`,
'Run with --json for the details. After you resolve them, run:',
` n8n-cli promotion-connection apply-continue ${shellQuote(connectionId)}` +
` --expected-config-id=${shellQuote(result.configId)}` +
` --expected-branch=${shellQuote(result.git.branchName)}` +
` --expected-commit-sha=${shellQuote(result.git.commitSha)}`,
].join('\n');
}
/** Branch names can contain shell metacharacters such as `;` and `$(`, so the shell must not interpret them. */
export function shellQuote(value: string): string {
if (/^[\w./@-]+$/.test(value)) return value;
return `'${value.replace(/'/g, "'\\''")}'`;
}

View File

@ -0,0 +1,32 @@
import { Args } from '@oclif/core';
import { PromotionApplyCommand } from './apply-command';
import { expectedSourceFlags, toExpectedSource } from './expected-source';
import { BaseCommand } from '../../base-command';
export default class PromotionConnectionApplyContinue extends PromotionApplyCommand {
static override description =
'Continue an Apply that was blocked on bindings, after they are set up. Pass the config ID, branch, and commit SHA that the blocked Apply reported. Exits 3 when the source changed since then and 4 when bindings are still missing; nothing is imported in either case.';
static override examples = [
'<%= config.bin %> promotion-connection apply-continue conn-1 --expected-config-id=cfg-1 --expected-branch=main --expected-commit-sha=<full sha>',
];
static override args = { id: Args.string({ description: 'Connection ID', required: true }) };
static override flags = {
...BaseCommand.baseFlags,
...expectedSourceFlags({ required: true }),
};
async run() {
const { args, flags } = await this.parse(PromotionConnectionApplyContinue);
const expectedSource = toExpectedSource(flags);
// oclif enforces the required flags, so this only narrows the type.
if (!expectedSource) this.error('Pass all three --expected-* flags');
await this.execute(async () => {
const result = await this.getClient(flags).continueApplyPackage(args.id, expectedSource);
this.reportApplyResult(result, flags, args.id);
});
}
}

View File

@ -1,26 +1,30 @@
import { Args } from '@oclif/core';
import { PromotionApplyCommand } from './apply-command';
import { expectedSourceFlags, toExpectedSource } from './expected-source';
import { BaseCommand } from '../../base-command';
export default class PromotionConnectionApply extends BaseCommand {
export default class PromotionConnectionApply extends PromotionApplyCommand {
static override description =
'Reset the local checkout to the configured branch tip and import the package, overwriting the instance to match. Requires the Apply direction to be cloned first.';
'Reset the local checkout to the configured branch tip and import the package, overwriting the instance to match. Pass the three --expected-* flags to apply only the reviewed commit. Exits 3 when the source changed since the review and 4 when bindings must be set up first; nothing is imported in either case. Requires the Apply direction to be cloned first.';
static override examples = ['<%= config.bin %> promotion-connection apply conn-1'];
static override examples = [
'<%= config.bin %> promotion-connection apply conn-1',
'<%= config.bin %> promotion-connection apply conn-1 --expected-config-id=cfg-1 --expected-branch=main --expected-commit-sha=<full sha>',
];
static override args = { id: Args.string({ description: 'Connection ID', required: true }) };
static override flags = { ...BaseCommand.baseFlags };
static override flags = {
...BaseCommand.baseFlags,
...expectedSourceFlags({ required: false }),
};
async run() {
const { args, flags } = await this.parse(PromotionConnectionApply);
await this.execute(async () => {
const result = await this.getClient(flags).applyPackage(args.id);
this.succeed(
`Applied ${result.git.branchName} at commit ${result.git.commitSha} to the instance.`,
flags,
result,
);
const result = await this.getClient(flags).applyPackage(args.id, toExpectedSource(flags));
this.reportApplyResult(result, flags, args.id);
});
}
}

View File

@ -0,0 +1,72 @@
import { Flags } from '@oclif/core';
import type { PromotionExpectedSource } from '../../client';
const COMMIT_SHA = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
/** The server compares the full SHA, so a short one could never match. */
export async function parseCommitSha(input: string): Promise<string> {
if (!COMMIT_SHA.test(input)) {
throw new Error(
`Expected a full lowercase commit SHA (40 or 64 hex characters), got "${input}"`,
);
}
return input;
}
/** An unset shell variable arrives as an empty value. Fail here, before any request is sent. */
export function parseNonEmpty(label: string) {
return async (input: string): Promise<string> => {
if (input.trim() === '') throw new Error(`Expected a non-empty ${label}`);
return input;
};
}
/**
* Flags that pin Apply to the reviewed source. `required: false` makes them
* optional as a group: pass all three or none.
*/
export function expectedSourceFlags({ required }: { required: boolean }) {
const group = ['expectedConfigId', 'expectedBranch', 'expectedCommitSha'];
const others = (name: string) => (required ? undefined : group.filter((flag) => flag !== name));
return {
expectedConfigId: Flags.string({
description: 'Apply config ID of the reviewed source (configs.apply.id)',
aliases: ['expected-config-id'],
required,
dependsOn: others('expectedConfigId'),
parse: parseNonEmpty('config ID'),
}),
expectedBranch: Flags.string({
description: 'Branch name of the reviewed source',
aliases: ['expected-branch'],
required,
dependsOn: others('expectedBranch'),
parse: parseNonEmpty('branch name'),
}),
expectedCommitSha: Flags.string({
description: 'Full commit SHA of the reviewed source',
aliases: ['expected-commit-sha'],
required,
dependsOn: others('expectedCommitSha'),
parse: parseCommitSha,
}),
};
}
export function toExpectedSource(flags: {
expectedConfigId?: string;
expectedBranch?: string;
expectedCommitSha?: string;
}): PromotionExpectedSource | undefined {
const { expectedConfigId, expectedBranch, expectedCommitSha } = flags;
if (
expectedConfigId === undefined ||
expectedBranch === undefined ||
expectedCommitSha === undefined
) {
return undefined;
}
return { configId: expectedConfigId, branchName: expectedBranch, commitSha: expectedCommitSha };
}

View File

@ -1,7 +1,15 @@
import { Args } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import { directionArg, toDirection } from './direction';
import { BaseCommand } from '../../base-command';
import type { PromotionChangesQuery } from '../../client';
const SORT_FIELDS: ReadonlyArray<NonNullable<PromotionChangesQuery['sort']>> = [
'name',
'updatedAt',
'status',
];
const SORT_ORDERS: ReadonlyArray<NonNullable<PromotionChangesQuery['order']>> = ['asc', 'desc'];
export default class PromotionConnectionListChanges extends BaseCommand {
static override description =
@ -9,6 +17,7 @@ export default class PromotionConnectionListChanges extends BaseCommand {
static override examples = [
'<%= config.bin %> promotion-connection list-changes proj-abc promote',
'<%= config.bin %> promotion-connection list-changes proj-abc apply --sort=updatedAt --order=desc',
];
static override args = {
@ -16,7 +25,18 @@ export default class PromotionConnectionListChanges extends BaseCommand {
direction: directionArg,
};
static override flags = { ...BaseCommand.baseFlags };
static override flags = {
...BaseCommand.baseFlags,
search: Flags.string({ description: 'Only list workflows whose name matches this text' }),
sort: Flags.string({
description: 'Field to sort by (server default: name)',
options: [...SORT_FIELDS],
}),
order: Flags.string({
description: 'Sort order (server default: asc)',
options: [...SORT_ORDERS],
}),
};
async run() {
const { args, flags } = await this.parse(PromotionConnectionListChanges);
@ -24,6 +44,11 @@ export default class PromotionConnectionListChanges extends BaseCommand {
const result = await this.getClient(flags).listProjectPromotionChanges(
args.projectId,
toDirection(args.direction),
{
search: flags.search,
sort: SORT_FIELDS.find((field) => field === flags.sort),
order: SORT_ORDERS.find((order) => order === flags.order),
},
);
// JSON and jq consumers want the `commitSha` + `changes` wrapper; table
// and id-only need the rows themselves, or the array serializes into one

View File

@ -36,6 +36,7 @@ import ProjectRemoveMember from './commands/project/remove-member';
import ProjectUpdate from './commands/project/update';
import PromotionConnectionAddProject from './commands/promotion-connection/add-project';
import PromotionConnectionApply from './commands/promotion-connection/apply';
import PromotionConnectionApplyContinue from './commands/promotion-connection/apply-continue';
import PromotionConnectionClone from './commands/promotion-connection/clone';
import PromotionConnectionCreate from './commands/promotion-connection/create';
import PromotionConnectionDelete from './commands/promotion-connection/delete';
@ -123,6 +124,7 @@ export const commands = {
'promotion-connection:promote': PromotionConnectionPromote,
'promotion-connection:promote-selection': PromotionConnectionPromoteSelection,
'promotion-connection:apply': PromotionConnectionApply,
'promotion-connection:apply-continue': PromotionConnectionApplyContinue,
'credential:list': CredentialList,
'credential:get': CredentialGet,