Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9472c69
fix: match a named value reference by display name only, ignoring cas…
cdayne Nov 5, 2026
11ea0c2
fix: ignore references in XML comments and Liquid output, and follow …
cdayne Nov 5, 2026
2405277
fix: prefix named value references by the named value they resolve to…
cdayne Nov 5, 2026
7c4f81e
fix: point logger and backend credential references at deployed displ…
cdayne Nov 5, 2026
6d4d020
fix: prefix every policy attribute that names a resource
cdayne Nov 5, 2026
0ec7db0
fix: stop publish before any change when a reference uses a named val…
cdayne Nov 5, 2026
1ef23ed
fix: include the named values logger and backend credentials use, as …
cdayne Nov 6, 2026
552a746
fix: include the resource a policy names through a named value's value
cdayne Nov 6, 2026
7ed773d
fix: follow a backend or fragment however the policy attribute naming…
cdayne Nov 6, 2026
e5444bb
fix: publish a named value after the resource it names, and what uses…
cdayne Nov 6, 2026
a5f6ff9
fix: publish a named value a policy uses as a resource name with that…
cdayne Nov 6, 2026
8ca3bec
docs: describe how named value references are matched, followed and r…
cdayne Nov 5, 2026
eae8ee2
fix: find a policy attribute after one holding a policy expression wi…
cdayne Nov 6, 2026
b98137f
fix: scan the policies an incremental publish republishes in the pre-…
cdayne Nov 6, 2026
7a08cae
fix: read C# comments in policy expressions, and find XML comments an…
cdayne Nov 6, 2026
9150d66
fix: match a pool member's workspace ignoring case when ordering name…
cdayne Nov 6, 2026
c4534f0
fix: read the code inside interpolated strings in policy expressions
cdayne Nov 6, 2026
dffdab2
fix: leave XML comments as written when rewriting policy references
cdayne Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/guides/environment-overrides.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The schema provides:

## Multi-environment on a single APIM instance

Override files support an optional top-level `environment:` block that enables publishing multiple environments (dev, qa, prod) to a **single shared APIM instance**. When present, resource names are prefixed and/or suffixed at publish time so dev, qa, and prod resources coexist without colliding. Cross-references in policy XML — `{{namedValue}}` tokens, fragment IDs, backend IDs, subscription scopes — are rewritten automatically to use the affixed names.
Override files support an optional top-level `environment:` block that enables publishing multiple environments (dev, qa, prod) to a **single shared APIM instance**. When present, resource names are prefixed and/or suffixed at publish time so dev, qa, and prod resources coexist without colliding. Cross-references — `{{namedValue}}` tokens in policies and credentials, the backend, fragment, logger and schema IDs policies name, named values used as those IDs, subscription scopes — are rewritten automatically to use the affixed names; see [Cross-reference rewrites](multi-environment-shared-apim.md#cross-reference-rewrites-automatic).

Override entries always use **canonical (unprefixed) names** matching the artifact files on disk, regardless of whether an `environment:` block is present. The tool applies the affix at publish time; you never write prefixed names in override files or artifact files.

Expand Down Expand Up @@ -295,6 +295,8 @@ loggers:
isBuffered: true
```

Logger credentials reference the auto-generated named value by its display name, `{{Logger-Credentials--<id>}}`, as APIM requires. Artifacts extracted by `v0.1.7-alpha.0` and `v0.2.0-alpha.0` reference it by its ID (`{{66f48e1226dab62c0823e4f8}}`); publish stops on those and names the display name to use, so re-extract or change the reference.

> **Tip:** You can also use [pipeline token substitution](token-substitution.md) for logger credentials. Replace the hardcoded value with a placeholder like `{#[APPINSIGHTS_KEY]#}`, and store the actual key in your pipeline's secret store. This keeps secrets out of your repository entirely.

### Subscriptions
Expand Down
10 changes: 10 additions & 0 deletions docs/guides/filtering-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,16 @@ And `petstore-api` is assigned to:

The extract output includes all of these, even though only `apiNames` was specified in the filter.

### Named value references

A `{{...}}` reference in a policy names the named value with that display name, ignoring case, as APIM does. On publish, that is the display name with your `--overrides` applied, or its display name in the source when an override changes it. APIM doesn't accept a reference to a named value's resource name, so extract doesn't follow one (it warns instead), and publish stops before making any change when a policy, fragment, or logger's or backend's credentials hold one, naming the display name to use. If extract can't list the named values, it tries each reference as a resource name instead, which it usually also is.

Named values referenced in logger and backend credentials are pulled in too.

A policy attribute set to a named value (`backend-id="{{backend-name}}"`, and likewise `logger-id` and `schema-id`) pulls in the named value and the resource its value names, in the same scope (except `<include-fragment fragment-id>`, where APIM doesn't substitute named values). Publish reads the value with your `--overrides` applied, so each environment gets the resource its own value names. A named value that is published also brings the resource its value names whenever any policy or fragment in the source uses it that way, even one the filter leaves out: APIM checks every policy on the target that uses a named value when its value changes. A resource that isn't in the source isn't published, so it has to exist on the target already. When a policy uses a named value to name a backend, logger or schema that is published in the same run, publish creates that resource before it updates the named value, and publishes policy fragments, and any backend or logger using the named value in its credentials, after it, since APIM rejects a named value change that would leave a policy using it pointing at a resource that doesn't exist. A secret or Key Vault named value's value isn't known, so the resource it names isn't pulled in; add that resource to the filter.

References inside XML comments are ignored. Inside a `<set-body template="liquid">` body, a `{{name}}` is followed only when a named value has that display name: APIM substitutes those there and leaves every other `{{ ... }}` (`{{body.id}}`, `{{ name }}`, `{{x | upcase}}`) to Liquid, so those are skipped without an error. If extract can't list the named values, it reports an error, since it can't tell the two apart.

### Why Transitive Matters

Without transitive resolution, publishing the extracted artifacts to a new APIM instance would fail — the API references a backend that doesn't exist, a named value that's missing, or a policy fragment that can't be found.
Expand Down
13 changes: 7 additions & 6 deletions docs/guides/multi-environment-shared-apim.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,17 +216,18 @@ NamedValues have both a `name` (resource key) and a `displayName` (the `{{token}

- Both the resource name **and** the displayName receive the affix.
- This ensures `{{my-token}}` in a policy becomes `{{dev-my-token}}` for dev — cross-env policy isolation is maintained automatically.
- If you set an explicit `displayName` in a NamedValue override entry, that value is used as-is (no affix applied to it).
- If you set an explicit `displayName` in a NamedValue override entry, that value is used as-is (no affix applied to it), and references to the named value are rewritten to it.
- APIM resolves a `{{token}}` by display name only (ignoring case), never by resource name, so a token is matched to the named value with that display name in the policy's own scope (service or workspace).

## Cross-reference rewrites (automatic)

When `appliesTo` includes the relevant types, `apiops publish` rewrites the following references in artifacts before sending them to APIM. You do not need to update artifact files or override files manually.
When `appliesTo` includes the relevant types, `apiops publish` rewrites the following references in artifacts before sending them to APIM. You do not need to update artifact files or override files manually. References are matched against every artifact in the source, so they are rewritten even when the filter or an incremental publish leaves out the resource they name.

| Reference | Location | How it is rewritten |
|---|---|---|
| `{{namedValueToken}}` | Policy XML | Token rewritten to affixed displayName when `NamedValue ∈ appliesTo` and the named value artifact exists. Unknown tokens pass through unchanged. |
| `fragment-id="..."` | Policy XML `<include-fragment>` | Fragment ID rewritten when `PolicyFragment ∈ appliesTo`. |
| `backend-id="..."` | Policy XML `<set-backend-service>` | Backend ID rewritten when `Backend ∈ appliesTo`. |
| `{{namedValueToken}}` | Policy and policy fragment XML; logger and backend `credentials` | Token matched to the named value with that display name (ignoring case, in the same scope) and rewritten to the display name it is deployed with: affixed when `NamedValue ∈ appliesTo`, or an explicit `displayName` override as written. Tokens no named value in the source has as its display name pass through unchanged. |
| `backend-id`, `embeddings-backend-id`, `fragment-id`, `logger-id`, `schema-id` | Policy XML `<set-backend-service>`, `<llm-content-safety>`, `<llm-semantic-cache-lookup>` / `<azure-openai-semantic-cache-lookup>`, `<include-fragment>`, `<log-to-eventhub>`, validate-content's `<content>` | Rewritten when the resource's type (`Backend`, `PolicyFragment`, `Logger`, `GlobalSchema`) is in `appliesTo` and the source has that resource in the policy's scope. A policy expression (`@(...)`) is left as is. |
| Named value `value` | ARM named value properties | When a policy uses the named value as a resource name (`backend-id="{{name}}"`, `logger-id`, `schema-id`) and its value names that resource in the source, the value is rewritten to the resource's deployed name. Secret, Key Vault and expression values are left as is. If the policies use it for types affixed differently, it is left as is with a warning. The value changes wherever the named value is used, including as plain text. |
| Subscription `scope` | ARM subscription properties | Trailing segment rewritten when the referenced type (Api or Product) is in `appliesTo`. |
| ApiRelease `apiId` | ARM release properties | Api ID segment rewritten when `Api ∈ appliesTo`. |
| ProductApi association | ARM resource path | Both product and API name segments rewritten per `appliesTo`. |
Expand Down Expand Up @@ -435,7 +436,7 @@ Publish each with its respective `--service-name` (shared APIM for dev/qa, dedic

## Limitations

- **Policy XML rewriting is regex-based.** The rewriter uses targeted regular expressions for `{{token}}`, `fragment-id="..."`, and `backend-id="..."` patterns. Tokens or attributes inside CDATA sections or XML comments may not be rewritten correctly. Avoid placing canonical resource name references inside CDATA blocks or comments in policy XML.
- **Policy XML rewriting is regex-based.** The rewriter uses targeted regular expressions for `{{token}}`, `fragment-id="..."`, and `backend-id="..."` patterns. XML comments are left as written, since APIM doesn't substitute named values inside them. Tokens or attributes inside CDATA sections may not be rewritten correctly. Avoid placing canonical resource name references inside CDATA blocks in policy XML.
- **Workspace container is not affixed.** The Workspace resource name itself is never prefixed/suffixed, even if `Workspace` is in `appliesTo`. Resources *inside* a workspace are affixed normally via their parent workspace's name.
- **`ServicePolicy` and singleton types are never affixed.** The service-level policy, API policies, product policies, and operation policies derive their identity from their parent resource and are handled transitively — see [Types that are never affixed](#types-that-are-never-affixed).
- **No `--strip-prefix` for extraction.** There is no built-in flag to strip environment prefixes when extracting from a shared APIM. Always extract from a dedicated source-of-truth APIM with canonical names.
2 changes: 1 addition & 1 deletion docs/reference/dependency-graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ apiops-cli solves this by organizing all 34 resource types into **4 tiers**. Eac

| Tier | Resources | Rule |
|------|-----------|------|
| **1** | NamedValue, Tag, Gateway, VersionSet, Backend, Logger, Group, PolicyFragment, GlobalSchema, PolicyRestriction, Documentation | No dependencies — safe to process in any order |
| **1** | NamedValue, Tag, Gateway, VersionSet, Backend, Logger, Group, PolicyFragment, GlobalSchema, PolicyRestriction, Documentation | No dependencies on other tiers. Within the tier, named values go first, since backends and loggers can use them; pool backends follow the backends they contain; and a named value a policy uses to name a backend, logger or schema published in the same run goes after that resource, with policy fragments after it in that case. A backend or logger in the run that uses such a named value in its credentials goes after the named value, and these are ordered so each comes after what it needs; only where that is impossible (a cycle, as when a backend uses in its credentials the named value that names it, or a pool containing it), the named values in it go first |
| **2** | Diagnostic, ServicePolicy, Product, Api | Depend only on Tier 1 resources |
| **3** | ProductPolicy, ProductGroup, ProductTag, ProductApi, ProductWiki, ApiPolicy, ApiTag, ApiDiagnostic, ApiOperation, ApiSchema, ApiRelease, ApiTagDescription, ApiWiki, McpServer, GraphQLResolver, GatewayApi, Subscription | Depend on Tier 1 and/or Tier 2 resources |
| **4** | ApiOperationPolicy, GraphQLResolverPolicy | Depend on Tier 3 resources |
Expand Down
29 changes: 20 additions & 9 deletions src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* ExtractConfig, FilterConfig, PublishConfig, OverrideConfig, InitConfig
*/

import { ApimServiceContext } from './types.js';
import type { ResourceType } from './resource-types.js';
import { ApimServiceContext, ResourceDescriptor } from './types.js';
import { LogLevel } from '../lib/logger.js';
import type { EnvMapping } from '../services/env-mapper.js';

Expand Down Expand Up @@ -74,16 +75,26 @@ export interface FilterConfig {
}

/**
* Sets of canonical artifact names used to gate policy XML reference rewriting.
* Populated once per publish from the full artifact descriptor list.
* What publish knows about the source to rewrite references from canonical to
* deployed names. Built once per publish from every artifact in the source,
* whatever the filter selects.
*/
export interface KnownArtifactSets {
/** Canonical NamedValue resource names */
namedValues: ReadonlySet<string>;
/** Canonical PolicyFragment resource names */
fragments: ReadonlySet<string>;
/** Canonical Backend resource names */
backends: ReadonlySet<string>;
/** Whether the source has a resource of `type` named `name` (any case) in `workspace`. */
has(type: ResourceType, name: string, workspace?: string): boolean;
/**
* The display name the named value a `{{reference}}` in `workspace` names
* (NamedValueNames) is deployed with: an explicit displayName override as
* written, otherwise with the environment's affix when it applies to named
* values. Undefined when no named value in the source has the reference as
* its display name.
*/
deployedNamedValueReference(reference: string, workspace?: string): string | undefined;
/**
* The resource types policies use a named value for as a resource name
* (backend-id="{{name}}"). Known within a publish run only; empty otherwise.
*/
namedValueTargetTypes(namedValue: ResourceDescriptor): ReadonlySet<ResourceType>;
}

export interface PublishConfig {
Expand Down
94 changes: 94 additions & 0 deletions src/services/named-value-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
/**
* The rules for what a `{{reference}}` to a named value means, in one place
* for extract and publish:
*
* - It names the named value in its own scope (the service, or one
* workspace) with that display name, ignoring case (NamedValueNames). APIM
* matches display names only: it rejects a policy whose reference is only a
* named value's resource name.
* - A policy attribute set to one (backend-id="{{name}}") names the resource
* its value gives, when the value is known (namedValueTargetName).
*
* Extract builds the names from the named values APIM lists; publish builds
* them from the artifacts. On publish a reference can use either the display
* name a named value is published with (overrides applied) or, where an
* override changes it, its display name in the source.
*/

/** A named value as far as matching references to it goes. */
export interface NamedValueEntry {
/** Its resource name. */
name: string;
/** Its display name (on publish, with overrides applied), when known. */
displayName?: unknown;
/**
* On publish, its display name in the source. A reference to it still names
* the named value where an override changes the display name; a display
* name another named value is published with wins over it.
*/
sourceDisplayName?: unknown;
}

/** The named values of one scope, matched as APIM matches a `{{reference}}`. */
export class NamedValueNames {
/** Lower-case display name → resource name. */
private readonly byReference = new Map<string, string>();

constructor(namedValues: Iterable<NamedValueEntry>) {
const entries = [...namedValues];
for (const key of ['sourceDisplayName', 'displayName'] as const) {
for (const entry of entries) {
const displayName = entry[key];
if (typeof displayName === 'string') {
this.byReference.set(displayName.toLowerCase(), entry.name);
}
}
}
}

/**
* The resource name of the named value with the reference as its display
* name, or undefined when none has it.
*/
resolve(reference: string): string | undefined {
return this.byReference.get(reference.toLowerCase());
}
}

/**
* NamedValueNames for each scope of the given named values, built on first
* use.
*/
export function namedValueNamesByScope(
namedValues: Iterable<NamedValueEntry & { workspace?: string }>
): (workspace: string | undefined) => NamedValueNames {
const byScope = new Map<string, Array<NamedValueEntry & { workspace?: string }>>();
for (const entry of namedValues) {
const scope = entry.workspace?.toLowerCase() ?? '';
byScope.set(scope, [...(byScope.get(scope) ?? []), entry]);
}
const built = new Map<string, NamedValueNames>();
return (workspace) => {
const scope = workspace?.toLowerCase() ?? '';
let names = built.get(scope);
if (!names) {
names = new NamedValueNames(byScope.get(scope) ?? []);
built.set(scope, names);
}
return names;
};
}

/**
* The resource name a named value's value gives a policy attribute set to it,
* or undefined when it gives none: a secret named value's value isn't known,
* nor is a Key Vault one's, and a value that is itself a policy expression or
* named value reference names no resource here.
*/
export function namedValueTargetName(json: Record<string, unknown>): string | undefined {
const properties = json.properties as Record<string, unknown> | undefined;
const value = typeof properties?.value === 'string' ? properties.value.trim() : '';
return properties?.secret === true || value.startsWith('@') || value.includes('{{') ? undefined : value || undefined;
}
11 changes: 11 additions & 0 deletions src/services/override-merger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ResourceDescriptor } from '../models/types.js';
import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
import { OverrideConfig, OverrideSection, OverrideEntry } from '../models/config.js';
import { logger } from '../lib/logger.js';
import { isAutoGeneratedId } from '../lib/auto-generated.js';
import { getNameFromNameParts, isSingletonType } from '../lib/resource-path.js';

/** Keys of OverrideConfig that hold OverrideSection values (excludes non-section fields). */
Expand Down Expand Up @@ -61,6 +62,16 @@ const GRANDCHILD_OVERRIDE_MAP: Partial<Record<ResourceType, {
[ResourceType.ApiOperationPolicy]: { parentSection: 'apis', childKey: 'operations', grandchildKey: 'policies' },
};

/**
* Whether publish skips a named value: one APIM generated (a logger's key,
* named with a 24-character hex ID) unless an override provides it. An
* instance that already has the logger already has its key; APIM can't
* recreate it on a new one without the override.
*/
export function skipsAutoGeneratedNamedValue(name: string, overrides?: OverrideConfig, workspace?: string): boolean {
return isAutoGeneratedId(name) && !hasNamedValueOverride(name, overrides, workspace);
}

/**
* Check whether a named value has an explicit override entry.
* Uses case-insensitive matching to align with override-merger behavior.
Expand Down
Loading