Skip to content

Commit 030353e

Browse files
authored
Merge pull request #47 from Azure/copilot/add-support-for-apiops-publish-commit-id
2 parents 5e75b24 + 4c772d0 commit 030353e

16 files changed

Lines changed: 713 additions & 75 deletions

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ Publish local artifact files to an Azure APIM service.
8484
| `--service-name <name>` | *(required)* | APIM service name |
8585
| `--source <dir>` | `./apim-artifacts` | Source artifacts directory |
8686
| `--overrides <path>` | | Path to overrides file |
87+
| `--commit-id <sha>` | | Git commit SHA for incremental publish |
8788
| `--dry-run` | | Preview changes without applying |
88-
| `--delete-unmatched` | | Delete resources not in artifacts |
89+
| `--delete-unmatched` | | Delete resources not in artifacts (mutually exclusive with `--commit-id`) |
8990

9091
```bash
9192
apiops publish --help
@@ -101,6 +102,12 @@ apiops publish \
101102
--resource-group <rg> \
102103
--service-name <name> \
103104
--delete-unmatched
105+
106+
# Incremental publish for a specific commit
107+
apiops publish \
108+
--resource-group <rg> \
109+
--service-name <name> \
110+
--commit-id <sha>
104111
```
105112

106113
### `apiops init`

specs/contracts/cli-commands.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ Publish local artifact files to an APIM instance.
5353
| `--service-name <name>` | string | yes || APIM service instance name |
5454
| `--source <dir>` | string | no | `./apim-artifacts` | Source artifact directory |
5555
| `--overrides <path>` | string | no || Environment overrides YAML file |
56+
| `--commit-id <sha>` | string | no || Git commit SHA for incremental publish (overrides `COMMIT_ID`) |
5657
| `--dry-run` | boolean | no | `false` | Show planned changes without applying |
57-
| `--delete-unmatched` | boolean | no | `false` | Delete APIM resources not in artifacts |
58+
| `--delete-unmatched` | boolean | no | `false` | Delete APIM resources not in artifacts. Mutually exclusive with `--commit-id`. |
5859

5960
**Environment variables** (publish-specific):
6061
| Variable | Description |

src/cli/publish-command.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ interface PublishOptions {
2323
serviceName: string;
2424
source: string;
2525
overrides?: string;
26+
commitId?: string;
2627
dryRun: boolean;
2728
deleteUnmatched: boolean;
2829
}
@@ -37,6 +38,10 @@ export function createPublishCommand(): Command {
3738
.requiredOption('--service-name <name>', 'APIM service instance name')
3839
.option('--source <dir>', 'Source directory with artifacts', './apim-artifacts')
3940
.option('--overrides <path>', 'Override configuration YAML file')
41+
.option(
42+
'--commit-id <sha>',
43+
'Git commit SHA for incremental publish (overrides COMMIT_ID env var)'
44+
)
4045
.option('--dry-run', 'Preview changes without applying them', false)
4146
.option(
4247
'--delete-unmatched',
@@ -111,12 +116,19 @@ async function executePublish(
111116
}
112117
}
113118

114-
// Get commit ID from environment (for incremental publish)
115-
const commitId = process.env.COMMIT_ID;
119+
// Resolve commit ID for incremental publish
120+
const commitId = options.commitId ?? process.env.COMMIT_ID;
116121
if (commitId) {
117122
logger.debug(`Using incremental publish with commit ID: ${commitId}`);
118123
}
119124

125+
if (hasMutuallyExclusivePublishOptions(options.deleteUnmatched, commitId)) {
126+
logger.error(
127+
'Options --commit-id (or COMMIT_ID) and --delete-unmatched are mutually exclusive.'
128+
);
129+
process.exit(2);
130+
}
131+
120132
// Build publish config
121133
const publishConfig: PublishConfig = {
122134
service: context,
@@ -145,6 +157,16 @@ async function executePublish(
145157
process.exit(result.exitCode);
146158
}
147159

160+
/**
161+
* Returns true when publish options combine mutually exclusive modes.
162+
*/
163+
export function hasMutuallyExclusivePublishOptions(
164+
deleteUnmatched: boolean,
165+
commitId?: string
166+
): boolean {
167+
return deleteUnmatched && Boolean(commitId);
168+
}
169+
148170
/**
149171
* T038: JSON output mode for publish.
150172
* Machine-readable JSON to stdout with action list and summary.

src/clients/apim-client.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,9 @@ export class ApimClient implements IApimClient {
388388

389389
// Poll for long-running operations
390390
if (response.status === 202) {
391-
await this.pollProvisioningState(context, descriptor);
391+
await this.pollProvisioningState(context, descriptor, {
392+
treatMissingAsSuccess: true,
393+
});
392394
}
393395

394396
return true;
@@ -692,14 +694,24 @@ export class ApimClient implements IApimClient {
692694

693695
private async pollProvisioningState(
694696
context: ApimServiceContext,
695-
descriptor: ResourceDescriptor
697+
descriptor: ResourceDescriptor,
698+
options: { treatMissingAsSuccess?: boolean } = {}
696699
): Promise<Record<string, unknown>> {
700+
const { treatMissingAsSuccess = false } = options;
701+
697702
for (let attempt = 0; attempt < ApimClient.MAX_POLLING_ATTEMPTS; attempt++) {
698703
await this.delay(ApimClient.POLL_INTERVAL_MS);
699704

700705
const resource = await this.getResource(context, descriptor);
701706

702707
if (!resource) {
708+
if (treatMissingAsSuccess) {
709+
logger.debug(
710+
`Resource no longer present; operation completed: ${buildResourceLabel(descriptor)}`
711+
);
712+
return {};
713+
}
714+
703715
// APIM can transiently return 404 while asynchronously provisioning a
704716
// resource (e.g. a Key Vault-backed named value). Treat a missing resource
705717
// as "not yet visible" and continue polling rather than aborting — the

src/lib/resource-path.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ const ASSOCIATION_TYPES = new Set<ResourceType>([
2020
ResourceType.GatewayApi,
2121
]);
2222

23+
const SUPPORTED_SPECIFICATION_EXTENSIONS = new Set([
24+
'yaml',
25+
'yml',
26+
'json',
27+
'graphql',
28+
'wsdl',
29+
'wadl',
30+
]);
31+
2332
/**
2433
* Fills all positional `{i}` tokens in a template string with `nameParts[i]`.
2534
* Throws if a placeholder index has no corresponding entry in `nameParts`.
@@ -385,6 +394,79 @@ export function parseArtifactPath(
385394
return undefined;
386395
}
387396

397+
/**
398+
* Parse a changed artifact file path into a ResourceDescriptor.
399+
*
400+
* This extends `parseArtifactPath` with support for supplemental artifact
401+
* files that belong to a resource but are not the primary info file.
402+
*
403+
* Currently supports:
404+
* - API specification files (`apis/{api}/specification.{ext}`)
405+
* - Workspace-scoped API specification files
406+
* (`workspaces/{workspace}/apis/{api}/specification.{ext}`)
407+
*/
408+
export function parseArtifactChangePath(
409+
baseDir: string,
410+
filePath: string
411+
): ResourceDescriptor | undefined {
412+
const directDescriptor = parseArtifactPath(baseDir, filePath);
413+
if (directDescriptor) {
414+
return directDescriptor;
415+
}
416+
417+
const relativePath = toTemplatePath(path.relative(baseDir, filePath));
418+
419+
const rootApiSpec = parseTemplatePath('apis/{0}/specification.{1}', relativePath);
420+
if (rootApiSpec && rootApiSpec.length === 2) {
421+
const apiName = getCapture(rootApiSpec, 0);
422+
const extension = getCapture(rootApiSpec, 1);
423+
if (apiName && isSupportedSpecificationExtension(extension)) {
424+
return {
425+
type: ResourceType.Api,
426+
nameParts: [apiName],
427+
};
428+
}
429+
}
430+
431+
const workspaceApiSpec = parseTemplatePath(
432+
'workspaces/{0}/apis/{1}/specification.{2}',
433+
relativePath
434+
);
435+
if (workspaceApiSpec && workspaceApiSpec.length === 3) {
436+
const workspace = getCapture(workspaceApiSpec, 0);
437+
const apiName = getCapture(workspaceApiSpec, 1);
438+
const extension = getCapture(workspaceApiSpec, 2);
439+
if (workspace && apiName && isSupportedSpecificationExtension(extension)) {
440+
return {
441+
type: ResourceType.Api,
442+
nameParts: [apiName],
443+
workspace,
444+
};
445+
}
446+
}
447+
448+
return undefined;
449+
}
450+
451+
function toTemplatePath(relativePath: string): string {
452+
return relativePath.split(path.sep).join('/');
453+
}
454+
455+
function getCapture(captures: string[], index: number): string | undefined {
456+
if (captures.length <= index) {
457+
return undefined;
458+
}
459+
return captures[index];
460+
}
461+
462+
function isSupportedSpecificationExtension(extension: string | undefined): boolean {
463+
if (!extension) {
464+
return false;
465+
}
466+
467+
return SUPPORTED_SPECIFICATION_EXTENSIONS.has(extension.toLowerCase());
468+
}
469+
388470
/**
389471
* Check if a resource type is a singleton (no list, only get).
390472
* Singletons have armPathSuffix ending with a fixed segment (no `{n}` placeholder).

src/services/api-publisher.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -255,23 +255,34 @@ async function publishApiChildren(
255255
// schemaId/typeName references in representations. Re-include operations that
256256
// carry such references so those links are explicitly restored via PUT.
257257
if (specImported) {
258+
// In incremental mode, avoid re-publishing operation artifacts after spec
259+
// import. This prevents stale operation JSON from overwriting newly
260+
// imported OpenAPI operation metadata (for example descriptions).
261+
const shouldRepublishSchemaRefOps = !config.commitId;
262+
258263
const operationDescriptors = allDescriptors.filter(
259264
(d) =>
260265
d.type === ResourceType.ApiOperation &&
261266
getNamePart(d.nameParts, 0) === getNamePart(apiDescriptor.nameParts, 0)
262267
);
263268

264-
const schemaRefOps = await filterOperationsWithSchemaRefs(
265-
store,
266-
config.sourceDir,
267-
operationDescriptors
268-
);
269+
if (shouldRepublishSchemaRefOps) {
270+
const schemaRefOps = await filterOperationsWithSchemaRefs(
271+
store,
272+
config.sourceDir,
273+
operationDescriptors
274+
);
269275

270-
if (schemaRefOps.length > 0) {
276+
if (schemaRefOps.length > 0) {
277+
logger.debug(
278+
`Re-publishing ${schemaRefOps.length} operation(s) with schema references after spec import for "${getNamePart(apiDescriptor.nameParts, 0)}"`
279+
);
280+
childDescriptors = [...childDescriptors, ...schemaRefOps];
281+
}
282+
} else {
271283
logger.debug(
272-
`Re-publishing ${schemaRefOps.length} operation(s) with schema references after spec import for "${getNamePart(apiDescriptor.nameParts, 0)}"`
284+
`Skipping schema-reference operation re-publish for "${getNamePart(apiDescriptor.nameParts, 0)}" in incremental mode`
273285
);
274-
childDescriptors = [...childDescriptors, ...schemaRefOps];
275286
}
276287

277288
// Re-include explicitly named schemas (non-auto-generated IDs).

src/services/dry-run-reporter.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export async function generateDryRunReport(
3535
client: IApimClient,
3636
context: ApimServiceContext,
3737
config: PublishConfig,
38-
targetDescriptors: ResourceDescriptor[]
38+
targetDescriptors: ResourceDescriptor[],
39+
incrementalDeletedDescriptors: ResourceDescriptor[] = []
3940
): Promise<DryRunReport> {
4041
const actions: DryRunAction[] = [];
4142
let creates = 0;
@@ -92,8 +93,47 @@ export async function generateDryRunReport(
9293
}
9394
}
9495

95-
// If delete-unmatched is enabled, calculate deletes
96-
if (config.deleteUnmatched) {
96+
// In incremental mode, use precomputed deleted descriptors from git diff.
97+
// Otherwise, if delete-unmatched is enabled, calculate full unmatched deletes.
98+
if (incrementalDeletedDescriptors.length > 0) {
99+
for (const descriptor of incrementalDeletedDescriptors) {
100+
try {
101+
const existing = await client.getResource(context, descriptor);
102+
103+
if (existing) {
104+
const action: DryRunAction = {
105+
operation: 'DELETE',
106+
type: descriptor.type,
107+
name: formatResourceName(descriptor),
108+
descriptor,
109+
};
110+
actions.push(action);
111+
deletes++;
112+
logger.info(`[DRY RUN] DELETE ${buildResourceLabel(descriptor)}`);
113+
} else {
114+
const action: DryRunAction = {
115+
operation: 'SKIP',
116+
type: descriptor.type,
117+
name: formatResourceName(descriptor),
118+
descriptor,
119+
};
120+
actions.push(action);
121+
skips++;
122+
logger.info(`[DRY RUN] SKIP ${buildResourceLabel(descriptor)} (already absent)`);
123+
}
124+
} catch {
125+
const action: DryRunAction = {
126+
operation: 'SKIP',
127+
type: descriptor.type,
128+
name: formatResourceName(descriptor),
129+
descriptor,
130+
};
131+
actions.push(action);
132+
skips++;
133+
logger.info(`[DRY RUN] SKIP ${buildResourceLabel(descriptor)} (error)`);
134+
}
135+
}
136+
} else if (config.deleteUnmatched) {
97137
const deleteActions = await computeDeleteActionsForDryRun(
98138
client,
99139
store,

0 commit comments

Comments
 (0)