Skip to content

Commit 17bba2d

Browse files
authored
Merge pull request #282 from Azure/public/backfill-v1.0.1
Backfill v1.0.1 public source and release metadata
2 parents 420b993 + 0b6afb7 commit 17bba2d

8 files changed

Lines changed: 147 additions & 24 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ 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.1] - 2026-09-04
9+
10+
### Features
11+
12+
- **Multi-environment publishing** - publish multiple environment-affixed API revisions to a shared APIM instance, with warnings when generated names exceed APIM limits
13+
- **Filtered publishing parity** - apply resource filters consistently during publish operations
14+
- **Gateway API reconciliation** - reconcile managed API assignments for gateways during publishing
15+
16+
### Bug Fixes
17+
18+
- **Revision-aware environment mapping** - preserve revision identity across mapped names, operation reconciliation, authentication overrides, and delete filtering
19+
- **API round-trip reliability** - preserve SOAP APIs and gateway associations while retrying pessimistic-concurrency conflicts and safely handling concurrent deletes
20+
- **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
21+
- **Publish validation** - reject explicitly empty environment resource scopes before planning deletes, and classify DELETE failures by HTTP status and structured error code
22+
- **Dependency security** - apply npm audit fixes and update `fast-uri` to 3.1.7
23+
24+
### Docs & Testing
25+
26+
- **Filtered-resource publishing guide** - document publishing behavior and examples for resource filters
27+
- **Transitive dependency coverage** - add extraction and publishing tests for transitive dependencies
28+
829
## [1.0.0] — 2026-08-27
930

1031
### 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.1",
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
@@ -834,9 +834,59 @@ describe('ApimClient.deleteResource revision and reference handling', () => {
834834
expect(url).not.toContain('deleteRevisions=true');
835835
});
836836

837-
// Bug 2: a policy fragment still referenced by the service policy cannot be
838-
// deleted; the prune should skip it (return false) rather than throw.
839-
it('skips a policy fragment that is still referenced by another entity', async () => {
837+
it('returns false when the initial DELETE reports HTTP 404', async () => {
838+
fetchSpy.mockResolvedValueOnce(
839+
makeResponse(404, {
840+
error: { code: 'ResourceNotFound', message: 'localized-resource-missing' },
841+
})
842+
);
843+
844+
const deleted = await client.deleteResource(testContext, {
845+
type: ResourceType.PolicyFragment,
846+
nameParts: ['missing-fragment'],
847+
});
848+
849+
expect(deleted).toBe(false);
850+
expect(fetchSpy).toHaveBeenCalledTimes(1);
851+
});
852+
853+
it('retries a delete conflict identified by HTTP 412 status', async () => {
854+
fetchSpy
855+
.mockResolvedValueOnce(
856+
makeResponse(412, {
857+
error: { code: 'Conflict', message: 'localized-precondition-failure' },
858+
})
859+
)
860+
.mockResolvedValueOnce(makeResponse(200, {}));
861+
862+
const deleted = await client.deleteResource(testContext, {
863+
type: ResourceType.Product,
864+
nameParts: ['starter-v2'],
865+
});
866+
867+
expect(deleted).toBe(true);
868+
expect(fetchSpy).toHaveBeenCalledTimes(2);
869+
});
870+
871+
it('retries a delete conflict identified by PreconditionFailed code', async () => {
872+
fetchSpy
873+
.mockResolvedValueOnce(
874+
makeResponse(400, {
875+
error: { code: 'PreconditionFailed', message: 'localized-conflict' },
876+
})
877+
)
878+
.mockResolvedValueOnce(makeResponse(200, {}));
879+
880+
const deleted = await client.deleteResource(testContext, {
881+
type: ResourceType.Product,
882+
nameParts: ['starter-v2'],
883+
});
884+
885+
expect(deleted).toBe(true);
886+
expect(fetchSpy).toHaveBeenCalledTimes(2);
887+
});
888+
889+
it('propagates a policy fragment ValidationError without a stable discriminator', async () => {
840890
const body = {
841891
error: {
842892
code: 'ValidationError',
@@ -847,12 +897,33 @@ describe('ApimClient.deleteResource revision and reference handling', () => {
847897
};
848898
fetchSpy.mockResolvedValueOnce(makeResponse(400, body));
849899

850-
const deleted = await client.deleteResource(testContext, {
851-
type: ResourceType.PolicyFragment,
852-
nameParts: ['global-security-headers'],
853-
});
900+
await expect(
901+
client.deleteResource(testContext, {
902+
type: ResourceType.PolicyFragment,
903+
nameParts: ['global-security-headers'],
904+
})
905+
).rejects.toMatchObject({ status: 400, code: 'ValidationError' });
906+
907+
expect(fetchSpy).toHaveBeenCalledTimes(1);
908+
});
909+
910+
it('propagates an unrelated ValidationError without retrying', async () => {
911+
fetchSpy.mockResolvedValueOnce(
912+
makeResponse(400, {
913+
error: {
914+
code: 'ValidationError',
915+
message: 'The resource name is invalid.',
916+
},
917+
})
918+
);
919+
920+
await expect(
921+
client.deleteResource(testContext, {
922+
type: ResourceType.PolicyFragment,
923+
nameParts: ['invalid-fragment'],
924+
})
925+
).rejects.toMatchObject({ status: 400, code: 'ValidationError' });
854926

855-
expect(deleted).toBe(false);
856927
expect(fetchSpy).toHaveBeenCalledTimes(1);
857928
});
858929
});

‎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)