Skip to content

Commit c14787c

Browse files
authored
Merge pull request #283 from Azure/public/prepare-v1.0.2
Prepare v1.0.2 public source and release metadata
2 parents 03612c6 + 58e1481 commit c14787c

8 files changed

Lines changed: 157 additions & 24 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,37 @@ All notable changes to the APIOps CLI are documented in this file.
55
The format is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66
This project uses [Semantic Versioning](https://semver.org/) with alpha pre-release tags.
77

8+
## [1.0.2] - 2026-09-11
9+
10+
### Bug Fixes
11+
12+
- **WSDL schema publishing** - skip importer-generated XSD schemas for WSDL imports, while preserving standalone schemas ([#272](https://github.com/Azure/apiops-cli/pull/272))
13+
- **Gateway dry runs** - prevent crashes when displaying gateway API association descriptors ([#272](https://github.com/Azure/apiops-cli/pull/272))
14+
- **Subscription owners** - normalize subscription owner IDs to relative `/users` paths when publishing across services ([#279](https://github.com/Azure/apiops-cli/pull/279))
15+
- **Portal-created schemas** - recognize 13-digit timestamp schema IDs and skip re-publishing only when the imported specification recreates structurally equivalent components ([#280](https://github.com/Azure/apiops-cli/pull/280))
16+
- **Sovereign-cloud publishing** - respect the selected cloud endpoint during publish pre-flight checks ([#281](https://github.com/Azure/apiops-cli/pull/281))
17+
18+
## [1.0.1] - 2026-09-04
19+
20+
### Features
21+
22+
- **Multi-environment publishing** - publish multiple environment-affixed API revisions to a shared APIM instance, with warnings when generated names exceed APIM limits
23+
- **Filtered publishing parity** - apply resource filters consistently during publish operations
24+
- **Gateway API reconciliation** - reconcile managed API assignments for gateways during publishing
25+
26+
### Bug Fixes
27+
28+
- **Revision-aware environment mapping** - preserve revision identity across mapped names, operation reconciliation, authentication overrides, and delete filtering
29+
- **API round-trip reliability** - preserve SOAP APIs and gateway associations while retrying pessimistic-concurrency conflicts and safely handling concurrent deletes
30+
- **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
31+
- **Publish validation** - reject explicitly empty environment resource scopes before planning deletes, and classify DELETE failures by HTTP status and structured error code
32+
- **Dependency security** - apply npm audit fixes and update `fast-uri` to 3.1.7
33+
34+
### Docs & Testing
35+
36+
- **Filtered-resource publishing guide** - document publishing behavior and examples for resource filters
37+
- **Transitive dependency coverage** - add extraction and publishing tests for transitive dependencies
38+
839
## [1.0.0] — 2026-08-27
940

1041
### Breaking Changes

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@azure-tools/apiops-cli",
3-
"version": "1.0.0",
3+
"version": "1.0.2",
44
"schemaVersion": "1",
55
"description": "CLI tool for Azure API Management configuration-as-code",
66
"type": "module",

‎src/clients/apim-client.ts‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -520,25 +520,15 @@ export class ApimClient implements IApimClient {
520520

521521
return true;
522522
} catch (error) {
523-
const message = (error as Error).message;
524-
if (message.includes('404')) {
525-
return false;
526-
}
527-
// Resource is still referenced by another entity (e.g. a policy fragment
528-
// used by the service policy). It cannot be deleted until the reference is
529-
// removed; skip it with a warning instead of failing the whole prune.
530-
if (message.includes('is used by the following entities')) {
531-
logger.warn(
532-
`Skipping delete of ${buildResourceLabel(descriptor)}: still referenced by another entity`
533-
);
523+
if (error instanceof HttpError && error.status === 404) {
534524
return false;
535525
}
536526
// Transient optimistic-concurrency conflict: cascade deletes of related
537527
// resources (subscriptions, product/gateway associations) can modify
538528
// the resource while its async DELETE is in flight. Retry the DELETE.
539529
const isConflict =
540-
message.includes('[PreconditionFailed]') ||
541-
(error instanceof HttpError && error.status === 412);
530+
error instanceof HttpError &&
531+
(error.status === 412 || error.code === 'PreconditionFailed');
542532
if (isConflict && attempt < ApimClient.DELETE_CONFLICT_RETRIES) {
543533
logger.warn(
544534
`Delete conflict for ${buildResourceLabel(descriptor)} ` +

‎src/services/env-mapping-validator.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ export function validateAndBuildEnvMapping(
6565

6666
// --- Validate appliesTo entries ----------------------------------------
6767
if (env.appliesTo !== undefined) {
68+
if (env.appliesTo.length === 0) {
69+
throw new Error(
70+
`[publish] environment.appliesTo must contain at least one resource type when specified.`
71+
);
72+
}
73+
6874
const validTypes = new Set(Object.values(ResourceType));
6975
const unknownTypes: string[] = [];
7076
const nonAffixableFound: string[] = [];

‎tests/unit/clients/apim-client.test.ts‎

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -874,9 +874,59 @@ describe('ApimClient.deleteResource revision and reference handling', () => {
874874
expect(url).not.toContain('deleteRevisions=true');
875875
});
876876

877-
// Bug 2: a policy fragment still referenced by the service policy cannot be
878-
// deleted; the prune should skip it (return false) rather than throw.
879-
it('skips a policy fragment that is still referenced by another entity', async () => {
877+
it('returns false when the initial DELETE reports HTTP 404', async () => {
878+
fetchSpy.mockResolvedValueOnce(
879+
makeResponse(404, {
880+
error: { code: 'ResourceNotFound', message: 'localized-resource-missing' },
881+
})
882+
);
883+
884+
const deleted = await client.deleteResource(testContext, {
885+
type: ResourceType.PolicyFragment,
886+
nameParts: ['missing-fragment'],
887+
});
888+
889+
expect(deleted).toBe(false);
890+
expect(fetchSpy).toHaveBeenCalledTimes(1);
891+
});
892+
893+
it('retries a delete conflict identified by HTTP 412 status', async () => {
894+
fetchSpy
895+
.mockResolvedValueOnce(
896+
makeResponse(412, {
897+
error: { code: 'Conflict', message: 'localized-precondition-failure' },
898+
})
899+
)
900+
.mockResolvedValueOnce(makeResponse(200, {}));
901+
902+
const deleted = await client.deleteResource(testContext, {
903+
type: ResourceType.Product,
904+
nameParts: ['starter-v2'],
905+
});
906+
907+
expect(deleted).toBe(true);
908+
expect(fetchSpy).toHaveBeenCalledTimes(2);
909+
});
910+
911+
it('retries a delete conflict identified by PreconditionFailed code', async () => {
912+
fetchSpy
913+
.mockResolvedValueOnce(
914+
makeResponse(400, {
915+
error: { code: 'PreconditionFailed', message: 'localized-conflict' },
916+
})
917+
)
918+
.mockResolvedValueOnce(makeResponse(200, {}));
919+
920+
const deleted = await client.deleteResource(testContext, {
921+
type: ResourceType.Product,
922+
nameParts: ['starter-v2'],
923+
});
924+
925+
expect(deleted).toBe(true);
926+
expect(fetchSpy).toHaveBeenCalledTimes(2);
927+
});
928+
929+
it('propagates a policy fragment ValidationError without a stable discriminator', async () => {
880930
const body = {
881931
error: {
882932
code: 'ValidationError',
@@ -887,12 +937,33 @@ describe('ApimClient.deleteResource revision and reference handling', () => {
887937
};
888938
fetchSpy.mockResolvedValueOnce(makeResponse(400, body));
889939

890-
const deleted = await client.deleteResource(testContext, {
891-
type: ResourceType.PolicyFragment,
892-
nameParts: ['global-security-headers'],
893-
});
940+
await expect(
941+
client.deleteResource(testContext, {
942+
type: ResourceType.PolicyFragment,
943+
nameParts: ['global-security-headers'],
944+
})
945+
).rejects.toMatchObject({ status: 400, code: 'ValidationError' });
946+
947+
expect(fetchSpy).toHaveBeenCalledTimes(1);
948+
});
949+
950+
it('propagates an unrelated ValidationError without retrying', async () => {
951+
fetchSpy.mockResolvedValueOnce(
952+
makeResponse(400, {
953+
error: {
954+
code: 'ValidationError',
955+
message: 'The resource name is invalid.',
956+
},
957+
})
958+
);
959+
960+
await expect(
961+
client.deleteResource(testContext, {
962+
type: ResourceType.PolicyFragment,
963+
nameParts: ['invalid-fragment'],
964+
})
965+
).rejects.toMatchObject({ status: 400, code: 'ValidationError' });
894966

895-
expect(deleted).toBe(false);
896967
expect(fetchSpy).toHaveBeenCalledTimes(1);
897968
});
898969
});

‎tests/unit/services/publish-service.env-mapping.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,18 @@ describe('validateAndBuildEnvMapping', () => {
131131

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

134+
it('environment with an empty appliesTo → throws', () => {
135+
const overrides: OverrideConfig = {
136+
environment: { namePrefix: 'dev-', appliesTo: [] },
137+
};
138+
const config = makeConfig(overrides);
139+
140+
expect(() => validateAndBuildEnvMapping(overrides, [], config)).toThrow(
141+
/environment\.appliesTo must contain at least one resource type/
142+
);
143+
expect(config.envMapping).toBeUndefined();
144+
});
145+
134146
it('appliesTo contains "ServicePolicy" → throws with clear message', () => {
135147
const overrides: OverrideConfig = {
136148
environment: {

‎tests/unit/services/publish-service.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,6 +1345,29 @@ describe('publish-service', () => {
13451345
expect(result.totalDeletes).toBe(1);
13461346
});
13471347

1348+
it('should reject an empty environment appliesTo before computing delete actions', async () => {
1349+
const client = createMockClient();
1350+
const store = createMockStore([]);
1351+
const config: PublishConfig = {
1352+
service: testContext,
1353+
sourceDir: '/source',
1354+
dryRun: false,
1355+
deleteUnmatched: true,
1356+
overrides: {
1357+
environment: { namePrefix: 'dev-', appliesTo: [] },
1358+
},
1359+
logLevel: LogLevel.INFO,
1360+
};
1361+
1362+
const result = await runPublish(client, store, config);
1363+
1364+
expect(result.exitCode).toBe(2);
1365+
expect(result.totalDeletes).toBe(0);
1366+
expect(computeDeleteActions).not.toHaveBeenCalled();
1367+
expect(client.putResource).not.toHaveBeenCalled();
1368+
expect(client.deleteResource).not.toHaveBeenCalled();
1369+
});
1370+
13481371
it('deletes a revisioned API via the base API only, not individual revisions', async () => {
13491372
const resources = [
13501373
{ type: ResourceType.Tag, nameParts: ['tag1'] },

0 commit comments

Comments
 (0)