From 0b6afb781154c77214a77dd1611155fe383eded5 Mon Sep 17 00:00:00 2001 From: Alexander Zaslonov Date: Fri, 11 Sep 2026 18:40:20 -0700 Subject: [PATCH] chore: backfill public v1.0.1 source and release metadata --- CHANGELOG.md | 21 +++++ package-lock.json | 4 +- package.json | 2 +- src/clients/apim-client.ts | 16 +--- src/services/env-mapping-validator.ts | 6 ++ tests/unit/clients/apim-client.test.ts | 87 +++++++++++++++++-- .../publish-service.env-mapping.test.ts | 12 +++ tests/unit/services/publish-service.test.ts | 23 +++++ 8 files changed, 147 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56af4820..6dbe377c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index e478b8c1..74738e20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure-tools/apiops-cli", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure-tools/apiops-cli", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@azure/identity": "^4.13.1", diff --git a/package.json b/package.json index ca0e0e47..f784243f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index 2e167ba9..b24754b3 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -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)} ` + diff --git a/src/services/env-mapping-validator.ts b/src/services/env-mapping-validator.ts index a5fb1580..0b0b9923 100644 --- a/src/services/env-mapping-validator.ts +++ b/src/services/env-mapping-validator.ts @@ -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[] = []; diff --git a/tests/unit/clients/apim-client.test.ts b/tests/unit/clients/apim-client.test.ts index 6758d881..d56691df 100644 --- a/tests/unit/clients/apim-client.test.ts +++ b/tests/unit/clients/apim-client.test.ts @@ -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', @@ -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); }); }); diff --git a/tests/unit/services/publish-service.env-mapping.test.ts b/tests/unit/services/publish-service.env-mapping.test.ts index 2e2b78e6..b81f4da9 100644 --- a/tests/unit/services/publish-service.env-mapping.test.ts +++ b/tests/unit/services/publish-service.env-mapping.test.ts @@ -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: { diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 8860c60a..503c3bef 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -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'] },