Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ All notable changes to the APIOps CLI are documented in this file.
The format is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org/) with alpha pre-release tags.

## [1.0.1] - 2026-09-04

### Features

- **Multi-environment publishing** - publish multiple environment-affixed API revisions to a shared APIM instance, with warnings when generated names exceed APIM limits
- **Filtered publishing parity** - apply resource filters consistently during publish operations
- **Gateway API reconciliation** - reconcile managed API assignments for gateways during publishing

### Bug Fixes

- **Revision-aware environment mapping** - preserve revision identity across mapped names, operation reconciliation, authentication overrides, and delete filtering
- **API round-trip reliability** - preserve SOAP APIs and gateway associations while retrying pessimistic-concurrency conflicts and safely handling concurrent deletes
- **Publishing order and cleanup** - publish APIs before products, use desired API manifests for unmatched-resource deletion, and skip missing or in-use associations instead of aborting
- **Publish validation** - reject explicitly empty environment resource scopes before planning deletes, and classify DELETE failures by HTTP status and structured error code
- **Dependency security** - apply npm audit fixes and update `fast-uri` to 3.1.7

### Docs & Testing

- **Filtered-resource publishing guide** - document publishing behavior and examples for resource filters
- **Transitive dependency coverage** - add extraction and publishing tests for transitive dependencies

## [1.0.0] — 2026-08-27

### Breaking Changes
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@azure-tools/apiops-cli",
"version": "1.0.0",
"version": "1.0.1",
"schemaVersion": "1",
"description": "CLI tool for Azure API Management configuration-as-code",
"type": "module",
Expand Down
16 changes: 3 additions & 13 deletions src/clients/apim-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,25 +520,15 @@ export class ApimClient implements IApimClient {

return true;
} catch (error) {
const message = (error as Error).message;
if (message.includes('404')) {
return false;
}
// Resource is still referenced by another entity (e.g. a policy fragment
// used by the service policy). It cannot be deleted until the reference is
// removed; skip it with a warning instead of failing the whole prune.
if (message.includes('is used by the following entities')) {
logger.warn(
`Skipping delete of ${buildResourceLabel(descriptor)}: still referenced by another entity`
);
if (error instanceof HttpError && error.status === 404) {
return false;
}
// Transient optimistic-concurrency conflict: cascade deletes of related
// resources (subscriptions, product/gateway associations) can modify
// the resource while its async DELETE is in flight. Retry the DELETE.
const isConflict =
message.includes('[PreconditionFailed]') ||
(error instanceof HttpError && error.status === 412);
error instanceof HttpError &&
(error.status === 412 || error.code === 'PreconditionFailed');
if (isConflict && attempt < ApimClient.DELETE_CONFLICT_RETRIES) {
logger.warn(
`Delete conflict for ${buildResourceLabel(descriptor)} ` +
Expand Down
6 changes: 6 additions & 0 deletions src/services/env-mapping-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export function validateAndBuildEnvMapping(

// --- Validate appliesTo entries ----------------------------------------
if (env.appliesTo !== undefined) {
if (env.appliesTo.length === 0) {
throw new Error(
`[publish] environment.appliesTo must contain at least one resource type when specified.`
);
}

const validTypes = new Set(Object.values(ResourceType));
const unknownTypes: string[] = [];
const nonAffixableFound: string[] = [];
Expand Down
87 changes: 79 additions & 8 deletions tests/unit/clients/apim-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,9 +834,59 @@ describe('ApimClient.deleteResource revision and reference handling', () => {
expect(url).not.toContain('deleteRevisions=true');
});

// Bug 2: a policy fragment still referenced by the service policy cannot be
// deleted; the prune should skip it (return false) rather than throw.
it('skips a policy fragment that is still referenced by another entity', async () => {
it('returns false when the initial DELETE reports HTTP 404', async () => {
fetchSpy.mockResolvedValueOnce(
makeResponse(404, {
error: { code: 'ResourceNotFound', message: 'localized-resource-missing' },
})
);

const deleted = await client.deleteResource(testContext, {
type: ResourceType.PolicyFragment,
nameParts: ['missing-fragment'],
});

expect(deleted).toBe(false);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});

it('retries a delete conflict identified by HTTP 412 status', async () => {
fetchSpy
.mockResolvedValueOnce(
makeResponse(412, {
error: { code: 'Conflict', message: 'localized-precondition-failure' },
})
)
.mockResolvedValueOnce(makeResponse(200, {}));

const deleted = await client.deleteResource(testContext, {
type: ResourceType.Product,
nameParts: ['starter-v2'],
});

expect(deleted).toBe(true);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});

it('retries a delete conflict identified by PreconditionFailed code', async () => {
fetchSpy
.mockResolvedValueOnce(
makeResponse(400, {
error: { code: 'PreconditionFailed', message: 'localized-conflict' },
})
)
.mockResolvedValueOnce(makeResponse(200, {}));

const deleted = await client.deleteResource(testContext, {
type: ResourceType.Product,
nameParts: ['starter-v2'],
});

expect(deleted).toBe(true);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});

it('propagates a policy fragment ValidationError without a stable discriminator', async () => {
const body = {
error: {
code: 'ValidationError',
Expand All @@ -847,12 +897,33 @@ describe('ApimClient.deleteResource revision and reference handling', () => {
};
fetchSpy.mockResolvedValueOnce(makeResponse(400, body));

const deleted = await client.deleteResource(testContext, {
type: ResourceType.PolicyFragment,
nameParts: ['global-security-headers'],
});
await expect(
client.deleteResource(testContext, {
type: ResourceType.PolicyFragment,
nameParts: ['global-security-headers'],
})
).rejects.toMatchObject({ status: 400, code: 'ValidationError' });

expect(fetchSpy).toHaveBeenCalledTimes(1);
});

it('propagates an unrelated ValidationError without retrying', async () => {
fetchSpy.mockResolvedValueOnce(
makeResponse(400, {
error: {
code: 'ValidationError',
message: 'The resource name is invalid.',
},
})
);

await expect(
client.deleteResource(testContext, {
type: ResourceType.PolicyFragment,
nameParts: ['invalid-fragment'],
})
).rejects.toMatchObject({ status: 400, code: 'ValidationError' });

expect(deleted).toBe(false);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/services/publish-service.env-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ describe('validateAndBuildEnvMapping', () => {

// ─── Error: non-affixable types ──────────────────────────────────────────

it('environment with an empty appliesTo → throws', () => {
const overrides: OverrideConfig = {
environment: { namePrefix: 'dev-', appliesTo: [] },
};
const config = makeConfig(overrides);

expect(() => validateAndBuildEnvMapping(overrides, [], config)).toThrow(
/environment\.appliesTo must contain at least one resource type/
);
expect(config.envMapping).toBeUndefined();
});

it('appliesTo contains "ServicePolicy" → throws with clear message', () => {
const overrides: OverrideConfig = {
environment: {
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/services/publish-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,29 @@ describe('publish-service', () => {
expect(result.totalDeletes).toBe(1);
});

it('should reject an empty environment appliesTo before computing delete actions', async () => {
const client = createMockClient();
const store = createMockStore([]);
const config: PublishConfig = {
service: testContext,
sourceDir: '/source',
dryRun: false,
deleteUnmatched: true,
overrides: {
environment: { namePrefix: 'dev-', appliesTo: [] },
},
logLevel: LogLevel.INFO,
};

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

expect(result.exitCode).toBe(2);
expect(result.totalDeletes).toBe(0);
expect(computeDeleteActions).not.toHaveBeenCalled();
expect(client.putResource).not.toHaveBeenCalled();
expect(client.deleteResource).not.toHaveBeenCalled();
});

it('deletes a revisioned API via the base API only, not individual revisions', async () => {
const resources = [
{ type: ResourceType.Tag, nameParts: ['tag1'] },
Expand Down