Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Publish local artifact files to an Azure APIM service.
| `--service-name <name>` | *(required)* | APIM service name |
| `--source <dir>` | `./apim-artifacts` | Source artifacts directory |
| `--overrides <path>` | | Path to overrides file |
| `--commit-id <sha>` | | Git commit SHA for incremental publish (overrides `COMMIT_ID`) |
Comment thread
EMaher marked this conversation as resolved.
Outdated
| `--dry-run` | | Preview changes without applying |
| `--delete-unmatched` | | Delete resources not in artifacts |

Comment thread
EMaher marked this conversation as resolved.
Expand All @@ -101,6 +102,12 @@ apiops publish \
--resource-group <rg> \
--service-name <name> \
--delete-unmatched

# Incremental publish for a specific commit
apiops publish \
--resource-group <rg> \
--service-name <name> \
--commit-id <sha>
```

### `apiops init`
Expand Down
1 change: 1 addition & 0 deletions specs/contracts/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Publish local artifact files to an APIM instance.
| `--service-name <name>` | string | yes | — | APIM service instance name |
| `--source <dir>` | string | no | `./apim-artifacts` | Source artifact directory |
| `--overrides <path>` | string | no | — | Environment overrides YAML file |
| `--commit-id <sha>` | string | no | — | Git commit SHA for incremental publish (overrides `COMMIT_ID`) |
Comment thread
EMaher marked this conversation as resolved.
| `--dry-run` | boolean | no | `false` | Show planned changes without applying |
| `--delete-unmatched` | boolean | no | `false` | Delete APIM resources not in artifacts |
Comment thread
EMaher marked this conversation as resolved.
Outdated

Expand Down
9 changes: 7 additions & 2 deletions src/cli/publish-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface PublishOptions {
serviceName: string;
source: string;
overrides?: string;
commitId?: string;
dryRun: boolean;
deleteUnmatched: boolean;
}
Expand All @@ -37,6 +38,10 @@ export function createPublishCommand(): Command {
.requiredOption('--service-name <name>', 'APIM service instance name')
.option('--source <dir>', 'Source directory with artifacts', './apim-artifacts')
.option('--overrides <path>', 'Override configuration YAML file')
.option(
'--commit-id <sha>',
'Git commit SHA for incremental publish (overrides COMMIT_ID env var)'
)
.option('--dry-run', 'Preview changes without applying them', false)
.option(
'--delete-unmatched',
Expand Down Expand Up @@ -111,8 +116,8 @@ async function executePublish(
}
}

// Get commit ID from environment (for incremental publish)
const commitId = process.env.COMMIT_ID;
// Resolve commit ID for incremental publish
const commitId = options.commitId ?? process.env.COMMIT_ID;
if (commitId) {
logger.debug(`Using incremental publish with commit ID: ${commitId}`);
}
Expand Down
20 changes: 17 additions & 3 deletions src/services/dry-run-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export async function generateDryRunReport(
client: IApimClient,
context: ApimServiceContext,
config: PublishConfig,
targetDescriptors: ResourceDescriptor[]
targetDescriptors: ResourceDescriptor[],
incrementalDeletedDescriptors: ResourceDescriptor[] = []
): Promise<DryRunReport> {
const actions: DryRunAction[] = [];
let creates = 0;
Expand Down Expand Up @@ -92,8 +93,21 @@ export async function generateDryRunReport(
}
}

// If delete-unmatched is enabled, calculate deletes
if (config.deleteUnmatched) {
// In incremental mode, use precomputed deleted descriptors from git diff.
// Otherwise, if delete-unmatched is enabled, calculate full unmatched deletes.
if (incrementalDeletedDescriptors.length > 0) {
for (const descriptor of incrementalDeletedDescriptors) {
const action: DryRunAction = {
operation: 'DELETE',
type: descriptor.type,
name: formatResourceName(descriptor),
descriptor,
};
actions.push(action);
deletes++;
logger.info(`[DRY RUN] DELETE ${buildResourceLabel(descriptor)}`);
Comment thread
EMaher marked this conversation as resolved.
Outdated
}
} else if (config.deleteUnmatched) {
const deleteActions = await computeDeleteActionsForDryRun(
client,
store,
Expand Down
51 changes: 44 additions & 7 deletions src/services/publish-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export interface PublishResult {
dryRunReport?: DryRunReport;
}

interface PublishTargets {
targetDescriptors: ResourceDescriptor[];
deletedDescriptors: ResourceDescriptor[];
}

/**
* Main publish orchestration function.
* Coordinates PUT/DELETE operations across dependency tiers.
Expand All @@ -63,7 +68,10 @@ export async function runPublish(
await client.validatePreFlight(config.service);

// Step 1: Determine target descriptors based on incremental mode
const targetDescriptors = await determineTargetDescriptors(store, config);
const { targetDescriptors, deletedDescriptors } = await determinePublishTargets(
store,
config
);

logger.debug(
`Publishing ${targetDescriptors.length} resources (dry-run: ${config.dryRun})`
Expand All @@ -76,7 +84,8 @@ export async function runPublish(
client,
config.service,
config,
targetDescriptors
targetDescriptors,
deletedDescriptors
);

return {
Expand All @@ -101,7 +110,13 @@ export async function runPublish(

// Step 4: Execute DELETEs in reverse dependency order (tier 4 → tier 1) if requested
let deleteResults: PublishActionResult[] = [];
if (config.deleteUnmatched) {
if (deletedDescriptors.length > 0) {
deleteResults = await executeDeletesForDescriptors(
client,
config.service,
deletedDescriptors
);
} else if (config.deleteUnmatched) {
deleteResults = await executeDeletes(
client,
store,
Expand Down Expand Up @@ -143,21 +158,27 @@ export async function runPublish(
/**
* Determine which resources to publish based on incremental mode.
*/
async function determineTargetDescriptors(
async function determinePublishTargets(
store: IArtifactStore,
config: PublishConfig
): Promise<ResourceDescriptor[]> {
): Promise<PublishTargets> {
if (config.commitId) {
// Incremental mode: use git diff
logger.debug(
`Using incremental publish mode with commit ID: ${config.commitId}`
);
const diffResult = await computeGitDiff(config.sourceDir, config.commitId);
return diffResult.changedDescriptors;
return {
targetDescriptors: diffResult.changedDescriptors,
deletedDescriptors: diffResult.deletedDescriptors,
};
} else {
// Full mode: publish all artifacts
logger.debug('Using full publish mode (all artifacts)');
return await store.listResources(config.sourceDir);
return {
targetDescriptors: await store.listResources(config.sourceDir),
deletedDescriptors: [],
};
}
}

Expand Down Expand Up @@ -462,6 +483,22 @@ async function executeDeletes(

logger.debug(`Deleting ${deleteDescriptors.length} unmatched resources`);

return executeDeletesForDescriptors(client, context, deleteDescriptors);
}

/**
* Execute DELETE operations for a precomputed descriptor list in reverse dependency order.
*/
async function executeDeletesForDescriptors(
client: IApimClient,
context: ApimServiceContext,
deleteDescriptors: ResourceDescriptor[]
): Promise<PublishActionResult[]> {
if (deleteDescriptors.length === 0) {
logger.debug('No resources to delete');
return [];
}

const results: PublishActionResult[] = [];

// Group by tier
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/cli/publish-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ describe('publish-command', () => {
expect(overridesOpt).toBeDefined();
});

it('should have --commit-id option', () => {
const cmd = createPublishCommand();
const opts = cmd.options;
const commitIdOpt = opts.find((o) => o.long === '--commit-id');
expect(commitIdOpt).toBeDefined();
});

it('should have --dry-run flag with default false', () => {
const cmd = createPublishCommand();
const opts = cmd.options;
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/services/dry-run-reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,27 @@ describe('dry-run-reporter', () => {
expect(report.summary.skips).toBe(0);
});

it('should include commit-scoped deletes even when delete-unmatched is false', async () => {
Comment thread
EMaher marked this conversation as resolved.
const client = createMockClient();
const store = createMockStore();

const report = await generateDryRunReport(
store,
client,
testContext,
testConfig,
[],
[{ type: ResourceType.Tag, nameParts: ['old-tag'] }]
);

expect(report.actions).toHaveLength(1);
expect(report.actions[0].operation).toBe('DELETE');
expect(report.summary.deletes).toBe(1);
expect(loggerInfoSpy).toHaveBeenCalledWith(
expect.stringContaining('[DRY RUN] DELETE')
);
});

it('should format hierarchical resource names correctly', async () => {
const client = createMockClient(new Map([
['ApiOperation:get-user', false],
Expand Down
69 changes: 69 additions & 0 deletions tests/unit/services/publish-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,75 @@ describe('publish-service', () => {
expect(computeGitDiff).toHaveBeenCalledWith('/source', 'abc123');
});

it('should delete descriptors removed in the commit when commitId is set', async () => {
const client = createMockClient();
const store = createMockStore([]);

vi.mocked(computeGitDiff).mockResolvedValue({
changedDescriptors: [
{ type: ResourceType.NamedValue, nameParts: ['nv1'] },
],
deletedDescriptors: [
{ type: ResourceType.Tag, nameParts: ['old-tag'] },
],
});

const config: PublishConfig = {
service: testContext,
sourceDir: '/source',
dryRun: false,
deleteUnmatched: false,
commitId: 'abc123',
logLevel: LogLevel.INFO,
};

const result = await runPublish(client, store, config);

expect(client.deleteResource).toHaveBeenCalledWith(
testContext,
expect.objectContaining({
type: ResourceType.Tag,
nameParts: ['old-tag'],
})
);
expect(result.totalDeletes).toBe(1);
expect(computeDeleteActions).not.toHaveBeenCalled();
});

it('should pass commit-scoped deleted descriptors to dry-run report', async () => {
const client = createMockClient();
const store = createMockStore([]);

vi.mocked(computeGitDiff).mockResolvedValue({
changedDescriptors: [
{ type: ResourceType.NamedValue, nameParts: ['nv1'] },
],
deletedDescriptors: [
{ type: ResourceType.Tag, nameParts: ['old-tag'] },
],
});

const config: PublishConfig = {
service: testContext,
sourceDir: '/source',
dryRun: true,
deleteUnmatched: false,
commitId: 'abc123',
logLevel: LogLevel.INFO,
};

await runPublish(client, store, config);

expect(generateDryRunReport).toHaveBeenCalledWith(
store,
client,
testContext,
config,
[{ type: ResourceType.NamedValue, nameParts: ['nv1'] }],
[{ type: ResourceType.Tag, nameParts: ['old-tag'] }]
);
});

it('should call computeDeleteActions when deleteUnmatched is true', async () => {
const resources = [
{ type: ResourceType.Tag, nameParts: ['tag1'] },
Expand Down