Skip to content

Commit 420b993

Browse files
authored
Merge pull request #236 from cdayne/feat/publish-filtering
feat: add --filter support to apiops publish
2 parents b694e4e + 19687f6 commit 420b993

38 files changed

Lines changed: 6234 additions & 774 deletions

‎README.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,11 @@ Publish local artifact files to an Azure APIM service.
8585
| `--service-name <name>` | *(required)* | APIM service name |
8686
| `--source <dir>` | `./apim-artifacts` | Source artifacts directory |
8787
| `--overrides <path>` | | Path to overrides file |
88+
| `--filter <path>` | | Filter YAML file (same format as `extract`) |
89+
| `--no-transitive` | | Publish only filter matches, without referenced dependencies |
8890
| `--commit-id <sha>` | | Git commit SHA for incremental publish |
8991
| `--dry-run` | | Preview changes without applying |
90-
| `--delete-unmatched` | | Delete resources not in artifacts (mutually exclusive with `--commit-id`) |
92+
| `--delete-unmatched` | | Delete resources absent from artifacts, or removed by an incremental commit (mutually exclusive with `--filter`) |
9193

9294
```bash
9395
apiops publish --help
@@ -109,8 +111,19 @@ apiops publish \
109111
--resource-group <rg> \
110112
--service-name <name> \
111113
--commit-id <sha>
114+
115+
# Publish a filtered subset; transitive dependencies are included by default
116+
apiops publish \
117+
--resource-group <rg> \
118+
--service-name <name> \
119+
--source ./apim-artifacts \
120+
--filter ./filter.yaml
112121
```
113122

123+
The publish filter uses the same YAML file and matching rules as `apiops extract --filter`.
124+
Referenced named values, backends (including backend pool members), policy fragments, and version
125+
sets are included automatically. Add `--no-transitive` to publish only the exact filter matches.
126+
114127
### `apiops init`
115128

116129
Scaffold a new APIM artifacts repository with CI/CD pipelines.

‎docs/architecture.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ flowchart TB
109109
extract_svc --> apim_client
110110
extract_svc --> store
111111
112+
publish_svc --> filter_svc
112113
publish_svc --> override_svc
113114
publish_svc --> git_svc
114115
publish_svc --> dry_svc

‎docs/architecture/overview.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ flowchart TD
161161
- **Dry-run mode** — `--dry-run` compares local artifacts against live APIM state and outputs a change report without modifying anything.
162162
- **Override merging** — Environment-specific override files are merged into artifact JSON before publishing, enabling promotion across environments (dev → staging → prod).
163163
- **Topological ordering** — Resources are published in dependency order (e.g., named values before APIs that reference them).
164-
- **Delete-unmatched** — Optionally removes APIM resources not present in the artifact source. Requires explicit opt-in and is mutually exclusive with `--commit-id`.
164+
- **Delete-unmatched** — Optionally removes APIM resources not present in the artifact source. With `--commit-id`, it deletes only resources removed by the selected commit. All deletion requires explicit opt-in.
165165

166166
## Design Principles
167167

‎docs/commands/publish.md‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ apiops publish \
4444
--commit-id abc123def456
4545
```
4646

47+
### Publish a filtered subset
48+
49+
```bash
50+
apiops publish \
51+
--resource-group my-rg \
52+
--service-name my-apim \
53+
--filter ./configuration.extractor.yaml
54+
```
55+
56+
The filter uses the same YAML format as `apiops extract --filter`. Referenced named values,
57+
backends (including backend pool members), policy fragments, and version sets are included by
58+
default; use `--no-transitive` to publish only exact matches. Product, gateway, and subscription
59+
links do not pull their API or Product targets into the publish set.
60+
4761
### Delete resources not in source
4862

4963
```bash
@@ -73,11 +87,13 @@ apiops publish \
7387
| `--service-name <name>` | string | — | Yes | APIM service instance name |
7488
| `--source <dir>` | string | `./apim-artifacts` | No | Source directory containing artifacts |
7589
| `--overrides <path>` | string | — | No | Override configuration YAML file |
90+
| `--filter <path>` | string | — | No | Filter YAML file shared with `extract` |
91+
| `--no-transitive` | boolean | `false` | No | Publish only exact filter matches |
7692
| `--commit-id <sha>` | string | env: `COMMIT_ID` | No | Git commit SHA for incremental publish |
7793
| `--dry-run` | boolean | `false` | No | Preview changes without applying |
78-
| `--delete-unmatched` | boolean | `false` | No | Delete APIM resources not present in source |
94+
| `--delete-unmatched` | boolean | `false` | No | Delete APIM resources absent from source, or removed by an incremental commit |
7995

80-
> **Note:** `--commit-id` and `--delete-unmatched` are **mutually exclusive**. The CLI will error if both are specified.
96+
> **Note:** `--filter` and `--commit-id` can be combined. `--delete-unmatched` cannot be combined with `--filter`; with `--commit-id`, it explicitly enables commit-scoped deletions.
8197
8298
### Global flags
8399

@@ -168,7 +184,7 @@ In CI/CD pipelines, this is typically set automatically:
168184
- run: npx apiops publish --commit-id ${{ github.event.before }}
169185
```
170186

171-
> **Tip:** Incremental publish cannot be combined with `--delete-unmatched` because delete-unmatched requires a full comparison between source and APIM.
187+
> **Tip:** Incremental publish is non-destructive by default. Add `--delete-unmatched` to delete resources removed by the selected commit; omit `--commit-id` for a full unmatched-resource cleanup.
172188

173189
## Dry run
174190

@@ -185,7 +201,7 @@ The output lists each resource and the planned action (create, update, or delete
185201

186202
## Delete unmatched
187203

188-
When `--delete-unmatched` is set, resources that exist in the APIM instance but are **not** present in the source artifacts are deleted. This enforces the source directory as the single source of truth.
204+
For a full publish, `--delete-unmatched` deletes APIM resources that are **not** present in the source artifacts. With `--commit-id`, it deletes only resources and Product associations removed by the selected commit.
189205

190206
> **Warning:** Use with caution. Resources created manually in the Azure portal that are not in your artifact directory will be removed.
191207

‎docs/guides/dry-run-workflow.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ apiops publish \
7373
```
7474
--- Dry-Run Report ---
7575
3 creates/updates
76+
0 patches
7677
1 deletes
7778
0 skipped
7879
@@ -83,11 +84,12 @@ Planned actions:
8384
DELETE NamedValue/old-key
8485
```
8586

86-
Each action shows the operation (`PUT`, `DELETE`, `SKIP`), the resource type, and the resource name.
87+
Each action shows the operation (`PUT`, `PATCH`, `DELETE`, `SKIP`), the resource type, and the resource name.
8788

8889
| Operation | Meaning |
8990
|-----------|---------|
9091
| `PUT` | Resource would be created (new) or updated (existing) |
92+
| `PATCH` | Resource would be partially updated |
9193
| `DELETE` | Resource would be removed from APIM |
9294
| `SKIP` | Resource could not be checked (error reading from APIM) |
9395

@@ -104,6 +106,7 @@ Each action shows the operation (`PUT`, `DELETE`, `SKIP`), the resource type, an
104106
],
105107
"summary": {
106108
"creates": 3,
109+
"patches": 0,
107110
"deletes": 1,
108111
"skips": 0
109112
}
@@ -219,11 +222,12 @@ This lets reviewers see _"this PR will create 2 APIs and update 1 backend"_ dire
219222
| `--dry-run` | Preview full publish |
220223
| `--dry-run --delete-unmatched` | Preview full publish + unmatched resource deletions |
221224
| `--dry-run --commit-id <sha>` | Preview incremental publish (changed files only) |
225+
| `--dry-run --commit-id <sha> --delete-unmatched` | Preview incremental publish including commit-scoped deletions |
222226
| `--dry-run --overrides config.yaml` | Preview publish with environment overrides applied |
223227
| `--dry-run --format json` | Machine-readable preview output |
224228
| `--dry-run --log-level debug` | Preview with verbose diagnostic logging |
225229

226-
> **Note:** `--commit-id` and `--delete-unmatched` remain mutually exclusive, even in dry-run mode.
230+
> **Note:** Incremental deletion is disabled unless `--delete-unmatched` is explicitly provided.
227231

228232
---
229233

‎docs/guides/filtering-resources.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Filtering Resources
22

3-
By default, `apiops extract` pulls every resource from your APIM instance. For large instances or multi-team setups, you can filter extraction to specific resources using a YAML filter file.
3+
By default, `apiops extract` pulls every resource from your APIM instance and `apiops publish` publishes every artifact in the source directory. For large instances or multi-team setups, you can use the same YAML filter file to limit either operation to specific resources.
44

55
## Why Filter?
66

@@ -34,6 +34,18 @@ apiops extract \
3434

3535
`petstore-api`, `orders-api`, and their transitive dependencies are extracted — along with every backend, named value, product, tag, workspace, and every other resource type, because those keys are omitted and therefore default to "include all". To narrow the extract to just these APIs, see [How To: Extract Just One API](#how-to-extract-just-one-api) below.
3636

37+
The same filter can limit publishing to a subset of the extracted artifacts:
38+
39+
```bash
40+
apiops publish \
41+
--resource-group my-rg \
42+
--service-name my-apim \
43+
--filter configuration.extractor.yaml
44+
```
45+
46+
Referenced dependencies are included by default; add `--no-transitive` to publish only direct filter
47+
matches.
48+
3749
---
3850

3951
## How To: Extract Just One API

‎docs/guides/incremental-publish.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,17 +140,17 @@ Force a full publish (omit `--commit-id`) when:
140140
- **Configuration drift** — someone changed APIM directly in the portal and you want to overwrite everything from git.
141141
- **Major refactoring** — renaming many APIs or restructuring directories. A full publish ensures nothing is missed.
142142
- **Override-only changes** — you updated an override file but no artifact files changed. See [Gotcha: Override-only changes are not published incrementally](environment-overrides.md#gotcha-override-only-changes-are-not-published-incrementally).
143-
- **You need `--delete-unmatched`** — see below.
143+
- **You need a full unmatched-resource cleanup** — incremental deletion only covers resources removed by the selected commit.
144144

145-
### `--commit-id` and `--delete-unmatched` are mutually exclusive
145+
### Incremental deletion requires explicit opt-in
146146

147-
You cannot combine incremental publish with `--delete-unmatched`. The CLI exits with an error if both are specified.
147+
By default, incremental publish does not delete resources. Add `--delete-unmatched` to delete resources whose artifacts or Product associations were removed by the selected commit:
148148

149-
```
150-
Options --commit-id (or COMMIT_ID) and --delete-unmatched are mutually exclusive.
149+
```bash
150+
apiops publish --commit-id abc123 --delete-unmatched ...
151151
```
152152

153-
**Why?** `--delete-unmatched` removes APIM resources that don't exist in the artifact directory — it requires a full view of all artifacts. Incremental publish only sees one commit's diff.
153+
This does not perform a full unmatched-resource scan. Omit `--commit-id` when you need to remove every APIM resource absent from the complete artifact source.
154154

155155
---
156156

@@ -162,7 +162,7 @@ Options --commit-id (or COMMIT_ID) and --delete-unmatched are mutually exclusive
162162
| `Commit <sha> not found; skipping incremental diff` | Shallow clone doesn't include the commit | Use `fetch-depth: 2` (or more) in your checkout step to include at least the parent commit. |
163163
| Nothing published, no errors | Commit diff returned no artifact file changes | Verify the commit actually touches files in the `--source` directory. Use `git diff --name-status HEAD~1 HEAD` locally to check. |
164164
| Nothing published after override change | Override file changed but no artifact files changed | Override files are not artifact files — they don't trigger resource selection. Run a full publish (omit `--commit-id`) or include an artifact file change in the same commit. See [Gotcha: Override-only changes](environment-overrides.md#gotcha-override-only-changes-are-not-published-incrementally). |
165-
| `mutually exclusive` error | Both `--commit-id` and `--delete-unmatched` specified | Remove one. Use `--commit-id` for incremental or `--delete-unmatched` for full sync — not both. |
165+
| Removed resources remain in APIM | Incremental deletion was not enabled | Add `--delete-unmatched` after reviewing a dry-run. |
166166

167167
### GitHub Actions: fetch depth
168168

‎docs/reference/configuration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Available on all commands (`extract`, `publish`, `init`):
6363
| `--dry-run` | Preview changes without applying | `false` |
6464
| `--delete-unmatched` | Delete resources not in artifacts | `false` |
6565

66-
> ⚠️ `--commit-id` and `--delete-unmatched` are **mutually exclusive**. You cannot use both.
66+
> ⚠️ Incremental deletion is disabled unless `--delete-unmatched` is combined with `--commit-id`. The combination deletes only resources removed by the selected commit.
6767
6868
### `apiops init` Flags
6969

‎docs/troubleshooting/common-errors.md‎

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -141,23 +141,15 @@ apiops init --environments dev prod
141141

142142
## Publish Errors
143143

144-
### "Options --commit-id and --delete-unmatched are mutually exclusive"
144+
### Removed resources remain after incremental publish
145145

146-
**Cause:** Both `--commit-id` and `--delete-unmatched` were specified. These flags conflict because:
146+
**Cause:** Incremental publishing is non-destructive by default.
147147

148-
- `--commit-id` publishes only changed resources (partial set)
149-
- `--delete-unmatched` deletes resources not in the source (requires full set)
150-
151-
Deleting based on a partial set would remove resources that were simply unchanged.
152-
153-
**Solution:** Use one or the other:
148+
**Solution:** Preview and then enable commit-scoped deletion:
154149

155150
```bash
156-
# Incremental publish (changed resources only)
157-
apiops publish --commit-id abc123 ...
158-
159-
# Full publish with cleanup (all resources, delete extras)
160-
apiops publish --delete-unmatched ...
151+
apiops publish --commit-id abc123 --delete-unmatched --dry-run ...
152+
apiops publish --commit-id abc123 --delete-unmatched ...
161153
```
162154

163155
---

‎src/cli/publish-command.ts‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
/**
44
* Publish command CLI registration
55
* Commander subcommand with --resource-group, --service-name, --source,
6-
* --overrides, --dry-run, --delete-unmatched flags.
6+
* --overrides, --filter, --no-transitive, --dry-run, --delete-unmatched flags.
77
* Includes --format json: machine-readable JSON output mode.
88
*/
99

1010
import { Command } from 'commander';
1111
import { PublishConfig } from '../models/config.js';
1212
import { ApimServiceContext } from '../models/types.js';
1313
import { runPublish, PublishResult } from '../services/publish-service.js';
14-
import { loadOverrideConfig } from '../lib/config-loader.js';
14+
import { loadFilterConfig, loadOverrideConfig } from '../lib/config-loader.js';
1515
import { logger, parseLogLevel } from '../lib/logger.js';
1616
import { ApimClient } from '../clients/apim-client.js';
1717
import { ArtifactStore } from '../clients/artifact-store.js';
@@ -25,6 +25,8 @@ interface PublishOptions {
2525
serviceName: string;
2626
source: string;
2727
overrides?: string;
28+
filter?: string;
29+
transitive: boolean;
2830
commitId?: string;
2931
dryRun: boolean;
3032
deleteUnmatched: boolean;
@@ -40,6 +42,8 @@ export function createPublishCommand(): Command {
4042
.requiredOption('--service-name <name>', 'APIM service instance name')
4143
.option('--source <dir>', 'Source directory with artifacts', './apim-artifacts')
4244
.option('--overrides <path>', 'Override configuration YAML file')
45+
.option('--filter <path>', 'Filter configuration YAML file')
46+
.option('--no-transitive', 'Disable transitive dependency inclusion')
4347
.option(
4448
'--commit-id <sha>',
4549
'Git commit SHA for incremental publish (overrides COMMIT_ID env var)'
@@ -121,15 +125,24 @@ async function executePublish(
121125
}
122126
}
123127

128+
let filterConfig;
129+
if (options.filter) {
130+
filterConfig = await loadFilterConfig(options.filter);
131+
if (!filterConfig) {
132+
logger.error(`Filter file not found: ${options.filter}`);
133+
process.exit(2);
134+
}
135+
}
136+
124137
// Resolve commit ID for incremental publish
125138
const commitId = options.commitId ?? process.env.COMMIT_ID;
126139
if (commitId) {
127140
logger.debug(`Using incremental publish with commit ID: ${commitId}`);
128141
}
129142

130-
if (hasMutuallyExclusivePublishOptions(options.deleteUnmatched, commitId)) {
143+
if (hasMutuallyExclusivePublishOptions(options.deleteUnmatched, commitId, Boolean(options.filter))) {
131144
logger.error(
132-
'Options --commit-id (or COMMIT_ID) and --delete-unmatched are mutually exclusive.'
145+
'Option --delete-unmatched cannot be combined with --filter.'
133146
);
134147
process.exit(2);
135148
}
@@ -138,6 +151,8 @@ async function executePublish(
138151
const publishConfig: PublishConfig = {
139152
service: context,
140153
sourceDir: options.source,
154+
filter: filterConfig,
155+
includeTransitive: options.transitive,
141156
overrides: overrideConfig,
142157
dryRun: options.dryRun,
143158
deleteUnmatched: options.deleteUnmatched,
@@ -167,9 +182,10 @@ async function executePublish(
167182
*/
168183
export function hasMutuallyExclusivePublishOptions(
169184
deleteUnmatched: boolean,
170-
commitId?: string
185+
_commitId?: string,
186+
hasFilter = false
171187
): boolean {
172-
return deleteUnmatched && Boolean(commitId);
188+
return deleteUnmatched && hasFilter;
173189
}
174190

175191
/**
@@ -182,6 +198,7 @@ function outputJson(result: PublishResult): void {
182198
exitCode: number;
183199
summary: {
184200
totalPuts: number;
201+
totalPatches: number;
185202
totalDeletes: number;
186203
totalErrors: number;
187204
totalSkipped: number;
@@ -198,9 +215,11 @@ function outputJson(result: PublishResult): void {
198215
operation: string;
199216
type: string;
200217
name: string;
218+
error?: string;
201219
}>;
202220
summary: {
203221
creates: number;
222+
patches: number;
204223
deletes: number;
205224
skips: number;
206225
};
@@ -215,6 +234,7 @@ function outputJson(result: PublishResult): void {
215234
exitCode: result.exitCode,
216235
summary: {
217236
totalPuts: result.totalPuts,
237+
totalPatches: result.totalPatches,
218238
totalDeletes: result.totalDeletes,
219239
totalErrors: result.totalErrors,
220240
totalSkipped: result.totalSkipped,
@@ -235,6 +255,7 @@ function outputJson(result: PublishResult): void {
235255
operation: a.operation,
236256
type: a.type,
237257
name: a.name,
258+
error: a.error,
238259
})),
239260
summary: result.dryRunReport.summary,
240261
};
@@ -257,6 +278,7 @@ function outputText(result: PublishResult, dryRun: boolean): void {
257278
process.stdout.write(
258279
`${result.dryRunReport.summary.creates} creates/updates\n`
259280
);
281+
process.stdout.write(`${result.dryRunReport.summary.patches} patches\n`);
260282
process.stdout.write(`${result.dryRunReport.summary.deletes} deletes\n`);
261283
process.stdout.write(`${result.dryRunReport.summary.skips} skipped\n`);
262284

@@ -272,7 +294,7 @@ function outputText(result: PublishResult, dryRun: boolean): void {
272294
// Regular publish mode summary
273295
process.stdout.write('\n--- Summary ---\n');
274296
process.stdout.write(
275-
`${result.totalPuts} creates/updates, ${result.totalDeletes} deletes, ${result.totalSkipped} skipped\n`
297+
`${result.totalPuts} creates/updates, ${result.totalPatches} patches, ${result.totalDeletes} deletes, ${result.totalSkipped} skipped\n`
276298
);
277299

278300
if (result.totalErrors > 0) {

0 commit comments

Comments
 (0)