Who wins when Bicep and the Azure portal disagree about a policy on the same API Management instance, and how do you find out before a partner does? In the order API from Parts 1 to 4, Bicep deploys every API, policy and product, so a fix someone makes in the portal's policy editor lasts exactly until the next azd up, and nothing records that it was ever there. APIOps moves the API surface into files that a pull request can review: extract what is live, commit it, and publish only approved commits. It only works if each resource has one owner, so the answer for the order API is a split: Bicep keeps the platform, APIOps takes the APIs, policies and products.

Part 4 ended with the products and the version set in Bicep and everything clicked in the portal living only in the instance. The companion repo now has an apiops/ folder: both order APIs, their version set and both products as files in the layout the APIOps tooling reads and writes, a filter that limits extraction to exactly those resources, and two GitHub Actions workflows: one extracts into a pull request every night, the other publishes what a merge approves. A new Bicep parameter, apiSurfaceOwner, decides whether Bicep or the publisher deploys that surface. It defaults to bicep, so everything from Parts 1 to 4 still deploys the way it did. The tool is the APIOps CLI (@azure-tools/apiops-cli, version 1.0.5), a single Node.js command that Microsoft Learn now documents and the Architecture Center recommends over the older APIOps Toolkit and its two binaries; its extract and publish commands are the extractor and publisher in the title. As in Part 4, nothing has been deployed. The Bicep builds, the workflows lint clean, the CLI's own filter code was run against the sample's filter, and the artifact tree was written by hand to match the Bicep. Where the article depends on how a live instance behaves, it says so.

The portal hotfix Git never saw

During an incident, someone opens the orders-partners product in the Azure portal, because the biggest partner is getting 429s. The committed product policy says:

 <rate-limit calls="60" renewal-period="60" /> 

They change 60 to 300, save, and the throttling stops. The next azd up deploys apim.bicep, which loads the product policy from the repo, and the limit is 60 again. The partner is throttled again, and whoever debugs it reads a repo that says 60 and has always said 60.

Neither tool did anything wrong. Bicep deployed what the file said; the portal saved what the operator typed. Two writers own the same resource, the last one wins, and neither knows the other exists.

APIOps attacks this from both ends. Extraction turns the portal edit into a diff: the 300 shows up in a pull request instead of being overwritten without a trace. Publishing makes the repo the only way the API surface changes, so the incident fix gets merged or reverted on purpose. Neither end helps while Bicep still deploys the same policy, which is why the sample splits ownership before it extracts anything.

Extract a baseline from the instance you already have

From ApimOrdersDemo/apiops, signed in with az login:

npx @azure-tools/apiops-cli@1.0.5 extract --resource-group <rg> --service-name <APIM_NAME> \ --filter configuration.extractor.yaml --no-transitive --output apim-artifacts 

The CLI needs Node.js 22 or later and signs in through DefaultAzureCredential: your Azure CLI session locally, whatever azure/login set up in a workflow. If the instance isn't in your default subscription, add the global --subscription-id. Extraction only reads, and Learn recommends separate, least-privilege identities for production: read access for the one that extracts, and only what publishing needs for the one that publishes. Reader on the instance and API Management Service Contributor, the two roles Learn lists, cover those. Pin the version (@1.0.5), as Learn recommends for CI; npx without one takes whatever is latest that morning.

Without --filter, extract writes everything the instance holds: the service-level policy, every API with its specification, operations, policies, diagnostics, tags, schemas and releases, every product with its policy and its API and group links, plus backends, named values, loggers, diagnostics, groups, subscriptions, version sets, policy fragments and gateways. For an instance one team owns end to end, that is the right first run. For the order API it would put half of what Bicep deploys into a second repo folder, with a second writer attached.

The exit code tells you how it went: 0 success, 1 partial (some resources failed, the rest were written), 2 fatal (authentication, invalid configuration, or nothing extracted). A pipeline step that fails on anything non-zero treats a partial extract as a failure, which is what you want from a baseline.

Filtering to what the repo owns

configuration.extractor.yaml:

# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/apiops-cli/main/schemas/v1/extractor-config.schema.json # The API surface this repo owns. An omitted key means "extract everything of that type", # so every Bicep-owned type is set to [] explicitly. apis: - orders: diagnostics: [] - orders-v2: diagnostics: [] versionSets: - orders products: - orders-free - orders-partners backends: [] namedValues: [] loggers: [] diagnostics: [] groups: [] subscriptions: [] policies: [] tags: [] policyFragments: [] gateways: [] schemas: [] policyRestrictions: [] documentations: [] workspaces: [] 

Each top-level key is independent, and a key you leave out means all resources of that type (the extract reference says so in a note that's easy to scroll past). A filter that lists only apis, versionSets and products looks right and still extracts every backend, named value, logger, group and subscription on the instance. That's why the file is mostly empty lists: every type Bicep owns is switched off by name. policies: [] is the service-level policy, which stays in infra/policies/global.xml.

The diagnostics: [] under each API is easy to miss. The Application Insights diagnostic on orders stays with Bicep in this split, and the top-level diagnostics key doesn't reach it: API diagnostics are children of the API and follow the API's own entry. Running the CLI's filter function from 1.0.5 against this file gives the expected answer for each case: orders, its operation policies and the product-to-group links are in; the orders/applicationinsights diagnostic, the orders-func backend, the tenant-id named value, the partners group, the global policy and the contoso-shop subscription are out.

The filter still isn't enough on its own. With a filter, the CLI follows transitive dependencies: it reads the selected APIs' policies and also extracts the backends, named values, policy fragments, tags and version sets they reference, even when that type's key is []. The template apiops init generates says so, and suggests --no-transitive to turn it off. The order API's policy references orders-func and four named values (tenant-id and the three app IDs from Part 3). Without --no-transitive, the first extract writes all of them into the repo, and the first publish PUTs them back over the values Bicep set from the Entra and Function outputs. In this layout, --no-transitive is what holds the boundary.

For every extract after the first, add --remove-stale. It deletes local files for resources inside the filter's scope that no longer exist on the instance, and only after a fully successful extraction. Without it, an API deleted in the portal keeps its folder in Git, and the next full publish recreates it. The extract workflow always passes it.

What lands on disk

The sample's apim-artifacts/ tree:

apim-artifacts ├── apis │ ├── orders │ │ ├── apiInformation.json │ │ ├── operations │ │ │ └── get-order │ │ │ └── policy.xml │ │ ├── policy.xml │ │ └── specification.yaml │ └── orders-v2 │ ├── apiInformation.json │ ├── operations │ │ └── list-orders │ │ └── policy.xml │ ├── policy.xml │ └── specification.yaml ├── products │ ├── orders-free │ │ ├── apis.json │ │ ├── groups.json │ │ ├── policy.xml │ │ └── productInformation.json │ └── orders-partners │ ├── apis.json │ ├── groups.json │ ├── policy.xml │ └── productInformation.json └── versionSets └── orders └── versionSetInformation.json 

Folder names are resource names. apis/orders-v2/ is the API whose ARM name is orders-v2, and the JSON inside doesn't repeat it. Renaming the folder doesn't rename the API; to the publisher it's a new API next to the old one.

Each *Information.json file holds the resource's ARM properties and nothing else: no id, type or name. Here is apis/orders-v2/apiInformation.json:

{ "properties": { "displayName": "Orders API", "apiVersionSetId": "/apiVersionSets/orders", "apiVersion": "v2", "apiVersionDescription": "list-orders returns a page of orders ({ items, nextLink }) instead of an array.", "path": "sales", "protocols": ["https"], "subscriptionRequired": true } } 

These are the same values apim.bicep sets on ordersApiV2. apiVersionSetId is a reference by name: before the PUT, the publisher keeps the last segment of the ID and rebuilds it for the target instance, so the same file publishes to dev and to prod. A live extract writes the source instance's full resource ID there; the hand-written file uses the short form, which the 1.0.5 source handles the same way.

One property is missing on purpose: serviceUrl. Bicep sets it to the function's URL, but no call ever uses it, because the API policy routes every request with set-backend-service backend-id="orders-func". It is also the one value that differs per environment. Leaving it out keeps a dev URL out of a prod publish. A live extract from the Bicep-deployed instance will write it back in, and that is the first line to look at in the baseline diff.

The spec sits next to the policy. specification.yaml is the OpenAPI file from Part 1, moved into the API's folder, and policy.xml beside it is the API-scope policy from Parts 2 and 3. Operation policies go one level down, in operations/<operationId>/policy.xml. The sample only has folders for the two operations that have a policy. A live extract also writes an operationInformation.json for every operation, all three per API; the sample leaves those out, because importing the spec creates the operations, and the first real extract will add them.

Products carry their policy (the 60-call limit lives in products/orders-partners/policy.xml now) and two link files. apis.json is the same for both products:

[ { "name": "orders" }, { "name": "orders-v2" } ] 

groups.json decides who sees the product in the developer portal: developers for the free product, partners for the partner one. The link is APIOps-owned; the groups aren't. partners comes from Bicep and developers is built in, so the publisher only connects the product to a group that must already exist.

What's missing is just as deliberate. A full extract would add a root policy.xml and the folders namedValues/, backends/, loggers/, diagnostics/, groups/ and subscriptions/, plus diagnostics/ inside apis/orders/. All of that is Bicep's in this split, and the filter keeps it out.

Gotcha: Toolkit trees look different. The legacy APIOps Toolkit (v7) writes named values/, version sets/ and policy fragments/ with spaces, and stores product links as folders (products/<product>/apis/<api>/productApiInformation.json) instead of apis.json. The CLI's migration guide says Toolkit artifacts "should work as-is", but the 1.0.5 source only knows the camel-case names. Don't merge a Toolkit-era tree into this layout by hand; the Toolkit section below covers how to check an existing tree.

Laying out the repository

The CLI's defaults (./apim-artifacts, configuration.extractor.yaml, configuration.<env>.yaml next to it) assume a repository that holds nothing but API Management configuration; apiops init scaffolds exactly that, workflows included. The order API's repo also holds the function code and the Bicep, so the APIOps files get their own folder inside the sample and the CLI runs from there:

apiops/ configuration.extractor.yaml the filter: the APIOps-owned names, [] for every Bicep-owned type configuration.prod.yaml prod overrides (orders-partners: one subscription per partner) apim-artifacts/ apis/, versionSets/, products/ in the APIOps CLI layout 

apiops/ sits next to infra/ and src/. The defaults still hold relative to it, and the workflows can trigger on ApimOrdersDemo/apiops/** without reacting to a change in the function code. There's no configuration.dev.yaml: dev is the lowest environment, and the artifacts already describe it.

Before this part, the spec lived in openapi/ and the policies in infra/policies/, one file per scope. Moving each API's contract and rules into one folder has two effects. A reviewer sees a removed operation and the operation policy that pointed at it in the same diff. And Bicep had to follow, because two deployable copies of one policy would drift. apim.bicep now loads them from the artifact tree:

 value: loadTextContent('../apiops/apim-artifacts/apis/orders/specification.yaml') 

There is one copy of each spec and policy that anything deploys, read by Bicep while apiSurfaceOwner is bicep and by the publisher once it is apiops. The files moved with git mv, so git log --follow still shows their history from Part 2 on. The old paths hold read-only mirrors, so links from Parts 1 to 4 still open a file; nothing deploys from them, and MIRRORS_ONLY=1 scripts/verify.sh fails if one drifts from the artifact tree.

One duplicate exists on purpose. In Part 4, orders and orders-v2 both loaded api-orders.xml. APIOps has one policy.xml per API folder, so that became two identical files. A change to the token check now has to touch both, and a pull request that only touches one shows it. global.xml stays in infra/policies/, because the global policy is platform: its CORS rule reads the portal-origin named value that Bicep computes from the instance name.

Committing the baseline

The first commit of apim-artifacts/ is the baseline, and it should contain nothing else: no fixes, no renames, no reformatting. Every pull request after it is then a diff of intent against what was live.

Extract from dev. The artifacts describe dev, and everything prod does differently goes through configuration.prod.yaml, so a baseline taken from prod would bake prod's values (subscriptionsLimit: 1, for one) into the files every environment publishes. Before git add, search the tree for secrets. The CLI redacts secret fields and policy patterns it recognizes and writes *** REDACTED *** in their place, but Learn is explicit that it "might not detect every embedded secret": a key someone pasted into a set-header value is still a key. In this layout the risk is small, because no named values are extracted, but the grep takes seconds. A marker that does slip through can't reach an instance: 1.0.5's publish refuses to run while any *** REDACTED *** is left in the source.

Then open the baseline as a pull request and review it as a question: is this what we think is live in dev? Prod gets the same question from the nightly extract, which compares the prod instance against these files. That's where the 300 from the incident turns up, and where you decide whether it goes back to 60 or stays.

The sample's baseline is the hand-written tree, so its first live extract from dev will produce a diff anyway: serviceUrl, the operationInformation.json files, and whatever formatting the instance applies to the policy XML it returns. Commit that as its own change, labelled as the extract, before anything else touches the folder. From then on, the extract workflow runs the same extract against dev and prod every night and opens a pull request with whatever drifted.

Named values and secrets: what extraction leaves out

The order API has one secret, the function key the orders-func backend sends as x-functions-key. It sits in the secret named value orders-function-key, which Bicep fills from the function app's output at deploy time. In this split it never touches the repo: the filter lists namedValues: [] and backends: [], and --no-transitive stops the extract from following the API policy to them. The apim-artifacts/ tree contains no secret, no redacted placeholder and no override that has to supply one. That is the strongest argument for the split, and it costs nothing: the values that differ per environment are exactly the ones Bicep already computes from other resources.

A repo that owns its named values needs a plan for every secret among them. An extract replaces the value with *** REDACTED ***, and, as above, publish stops on that marker. The CLI's environment overrides guide describes two ways to supply the real value per environment: a Key Vault reference in the environment's override file (the target instance's managed identity then needs read access to the vault, which is infrastructure again), or a {#[TOKEN]#} placeholder that the pipeline replaces from a GitHub environment secret before it publishes. Both work. Neither is needed while Bicep keeps the secret.

Publish: dry run first, then the approved commit

From the same folder, against the same instance:

npx @azure-tools/apiops-cli@1.0.5 publish --resource-group <rg> --service-name <APIM_NAME> \ --source apim-artifacts --dry-run 

Without --dry-run, the same command applies the plan. With it, nothing is written. The CLI checks that the resource group and instance exist, validates the overrides against the artifacts, scans every artifact it would publish for *** REDACTED ***, and then prints one planned call per resource: PUT VersionSet/orders, PUT Api/orders, PUT ProductPolicy/orders-partners and so on, in the order it would send them. The version set and products come first, then the APIs, the product policies and links, the API policies, and the operations and their policies last. Running the CLI's dry-run reporter locally against the sample tree, with a stubbed Azure client instead of an instance, gives 19 planned calls: the 11 artifacts, the six links from the two products' apis.json and groups.json, and two PUT ApiOperation calls for get-order, one per API. Those two don't come from a folder. After importing a spec, 1.0.5 re-PUTs every operation whose spec entry has no description and sets the description to null, and get-order is the only operation in either spec without one. Add --format json and the same report becomes machine-readable for a pipeline gate.

The dry run is a plan of calls, not a diff. It does a GET per resource only to mark the ones that don't exist yet as (new); it doesn't compare properties. An unchanged policy shows up as PUT exactly like a changed one. What changed is in the Git diff of the pull request. The dry run answers the other question: will this run, against this instance, with these overrides? Learn adds the obvious caveat that a successful dry run doesn't test policies, permissions or backend connectivity.

The first publish onto an instance Bicep built

The order API's instance already has orders, orders-v2, the version set and both products, all deployed by Bicep. Set apiSurfaceOwner to apiops, run azd up, and Bicep stops deploying them. ARM deployments in incremental mode don't delete what a template no longer mentions, so the resources stay where they are. The first publish then PUTs resources with the same names, which updates them in place. There's no second API, the version set keeps both versions, and contoso-shop keeps its key because the orders-partners product it belongs to was updated, not recreated.

Expect two differences. The API is PUT together with its specification, so the operations follow the spec; anything an operation got only in the portal is a candidate to disappear, and a baseline extract before the switch is how you find it. And serviceUrl, which the artifact leaves out, may or may not survive a PUT that doesn't mention it; the policy routes through orders-func either way, so the answer changes nothing for a call, but it changes the next extract's diff. Neither has been observed on a live instance for this sample; a dry run and a publish to dev answer both before prod sees anything.

Safe to re-run, not a no-op

A full publish, without --commit-id, PUTs every artifact in the folder every time. Run it twice and the second run writes the same state again: same policies, same products, same links. That makes it safe to re-run after a partial failure (exit code 1 means some resources failed and the rest were written). It doesn't make it free. Every policy PUT is a write the gateway picks up, and every resource gets an entry in the activity log, which is why the workflows publish per commit and keep the full publish for a manual run.

Full, per commit, and the flag that deletes things

There are four ways to call publish, and they differ in what they touch:

The four publish modes: what no --commit-id, --commit-id, --commit-id with --delete-unmatched and --delete-unmatched alone publish and delete

--commit-id publishes the changes made by one commit, not everything since it. The publish workflow passes the merged commit (github.sha), so merge or squash pull requests: a rebase merge of three commits lands as three commits, and only the last one gets published. The publish reference describes it differently ("changed since that commit", with github.event.before in its example), but the 1.0.5 source diffs <sha>~1 against <sha>. With that source, github.event.before would publish the last commit of the previous push, not the one that just landed. I reported the mismatch as apiops-cli#333.

The last row is the dangerous one. Without a commit, --delete-unmatched lists every non-system resource on the instance and deletes each one the folder doesn't have. In this repo the folder has no backend, no named values, no logger, no global policy and no subscription, so the first such run deletes the platform Bicep built. Learn's advice is to leave the flag out of your first workflow. In this layout, leave it out of every workflow. The CLI refuses to combine it with --filter anyway, so you can't scope it down to the APIOps-owned types.

One set of files, several environments

Dev is the lowest environment, so the artifacts describe it and it gets no override file. Prod gets configuration.prod.yaml:

# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/apiops-cli/main/schemas/v1/override-config.schema.json products: - name: orders-partners properties: subscriptionsLimit: 1 

In dev, a partner can hold as many subscriptions to orders-partners as a test needs. In prod, each partner gets one key: a second subscription would get the same 60-call budget again and make the product rate limit meaningless. The override merges into the product's productInformation.json at publish time, so the prod step passes it next to the source:

 - run: > npx --yes "$APIOPS_CLI" publish --subscription-id ${{ vars.AZURE_SUBSCRIPTION_ID }} --resource-group ${{ vars.APIM_RESOURCE_GROUP }} --service-name ${{ vars.APIM_SERVICE_NAME }} --source apim-artifacts --overrides configuration.prod.yaml --commit-id ${{ github.sha }} 

Overrides change properties, never names. orders-partners is orders-partners in every environment, and an override can't point an artifact at a differently named resource. An environment that needs other names needs its own artifacts.

A one-line override file is unusually small for a real API, and the split is the reason. The values that usually fill override files (backend URLs, named values, Key Vault references, logger keys) are all on the Bicep side here, where they're computed per environment from the resources they describe. What's left for APIOps is the API surface, which should look the same everywhere except for deliberate product decisions like this one.

Reviewing an API change in a pull request

Here is a change the pull request makes visible. A partner integration asks for a read-only client, and someone adds the role to the token check in apis/orders/policy.xml:

--- a/ApimOrdersDemo/apiops/apim-artifacts/apis/orders/policy.xml +++ b/ApimOrdersDemo/apiops/apim-artifacts/apis/orders/policy.xml @@ -14,5 +14,6 @@ <required-claims> <claim name="roles" match="any"> <value>Orders.ReadWrite</value> + <value>Orders.Read</value>  </claim> </required-claims> 

One added line, and it reads like a harmless extension. It isn't. match="any" means a token with only Orders.Read now passes the check for every operation, including POST /orders, because the policy sits at API scope. The read-only client can create orders. In the portal, that edit is a save button. In the pull request, it's a line a reviewer can ask about: should the role check move to the operation scope, with create-order keeping Orders.ReadWrite?

The diff shows a second problem by what's missing. apis/orders-v2/policy.xml holds the same policy and isn't in the change, so the new role works on /sales/orders and fails on /sales/v2/orders. With two copies of one policy, the file list of the pull request is part of the review.

What should trigger a review

Put a CODEOWNERS entry on ApimOrdersDemo/apiops/ so every change under it needs an API owner's approval, then read some files more carefully than others:

  • Any policy.xml. Authentication, rate limits, CORS, caching keys and backend routing all live here, and a one-line change opens or closes the API. A product policy applies to every API in the product.
  • specification.yaml. An added operation or optional field is safe. A removed or renamed operation, or a new required field, breaks callers; that's a new version in the version set from Part 4, not an edit.
  • apiInformation.json and productInformation.json. path, subscriptionRequired, apiVersionSetId, approvalRequired and state decide who can reach the API at all.
  • apis.json and groups.json. orders-free is auto-approved, so an API added to its apis.json is open to anyone who signs up in the developer portal.
  • configuration.prod.yaml. It only applies in prod, so it's the change dev never tested.
  • Every deleted file. With the sample's workflows nothing gets deleted in APIM, which is its own surprise: the API stays live after its folder is gone.

What the reviewer actually checks

Read a policy change in its scope. A product policy runs for every API in the product; an API policy for every operation; and a narrower policy only runs the broader one where it calls <base /> (Part 2). A pull request that adds an operation policy without <base /> silently drops the token check for that operation, and the diff shows that as an innocent new file.

Then open the dry-run check on the pull request and compare its plan with the description. A change described as "raise the partner limit" should plan one PUT ProductPolicy/orders-partners. If it plans Api/orders as well, ask why.

The last checks are about the boundary: no secret and no *** REDACTED *** in the diff, an override for every value that has to differ in prod, and no file that belongs to a Bicep-owned resource. A pull request that adds apim-artifacts/backends/ is trying to give the backend a second owner.

When a merged change turns out wrong, the rollback is git revert and the same publish run, not a fix in the portal. The Architecture Center says a revert "still requires validation and a controlled publish", which is the point: the revert goes through the same pull request and dry run as the change it undoes.

Drift: when the portal and Git disagree

The baseline only describes the instance on the day you extracted it. After that, the portal is still open, and every edit there is drift. The extract workflow is the drift detector. It extracts with the committed filter, --no-transitive and --remove-stale, and opens a pull request labelled extract with whatever differs. The incident from the start comes back in the prod pull request as a one-line diff in products/orders-partners/policy.xml, 60 against 300. Commit the 300 and the hotfix becomes the committed state. Publish the 60 instead, and the instance goes back to it, this time on purpose.

It runs every night against both instances, and on demand against the one you pick:

on: schedule: - cron: '17 3 * * *' workflow_dispatch: inputs: environment: description: Instance to extract from type: choice options: [dev, prod] default: dev 

A scheduled run has no inputs, so the job's matrix falls back to both environments, while a manual run gets a one-element list:

jobs: extract: # A scheduled run has no inputs, so it extracts both instances. strategy: fail-fast: false matrix: environment: ${{ fromJSON(inputs.environment && format('["{0}"]', inputs.environment) || '["dev", "prod"]') }} # One run per environment at a time: both would push to the same PR branch. concurrency: group: apiops-extract-${{ matrix.environment }} cancel-in-progress: false 

fail-fast: false keeps a failed dev extract from cancelling the prod one. A quiet night extracts, finds no diff and opens nothing. A night with drift opens a pull request per instance, from the branch apiops-extract/dev or apiops-extract/prod, and the nights after it don't pile up more pull requests: create-pull-request reuses that branch, updates the open pull request with each new extract, and closes it once the instance matches main again. The concurrency group sits on the job so it can read the matrix, and keeps a manual run and the nightly one from pushing to the same branch at the same time. The portal fix from the start was made in prod, so it is the prod run that reports it the next morning. That run signs in through a prod-read environment with a Reader identity instead of prod: required reviewers apply to every job that names an environment, even one that only reads and sets deployment: false, and the nightly extract would otherwise wait for an approval every night. GitHub runs schedules from the default branch only, and in a public repository it disables them after 60 days without activity.

Three details decide whether the drift pull request tells the truth:

  • The prod pull request is never clean. The artifacts describe dev, and extract has no --overrides option: it writes what the instance returns and can neither apply nor strip configuration.prod.yaml. Every prod extract shows subscriptionsLimit: 1 as a change, plus the values that name the instance once a live dev extract has been committed. That's the override, not drift. Leave the prod pull request open as a standing report, never merge it, and move the real drift, like the 300, into a branch of its own.
  • Per-commit publishing leaves drift alone until it doesn't. A portal edit to a resource no commit touches survives every --commit-id publish. The next commit that changes that file, or the next full publish, overwrites it without a word. Extract before that happens, not after.
  • The drift pull request has no dry run until someone pushes to it. The workflow opens it with the default GITHUB_TOKEN, and events created with that token don't start other workflows. Pushing a commit to the branch starts the check, but only until the next night: the next run rebuilds the branch from main and force-pushes it, and the hand-pushed commit is gone. Switching create-pull-request to a GitHub App token gets the check on every update.

Bicep and APIOps on the same instance: who owns what

Microsoft's Architecture Center doesn't make you pick one tool: infrastructure as code and APIOps "can serve different parts of the same solution", and APIOps fits when API definitions and policies "have separate owners or a release lifecycle that's independent of the service infrastructure". What it doesn't say loudly is that neither tool notices the other. If both deploy the same policy, the later one wins, and nothing warns either side.

So the sample gives every resource exactly one owner:

Resource ownership on the order API's instance: Bicep keeps the platform, APIOps owns the version set, both APIs with their policies, and both products

The switch is one parameter in apim.bicep:

// Part 5: with 'apiops', the version set, both APIs with their policies, and both products with their // policies and links come from `apiops publish` instead. Bicep keeps the platform either way. @allowed(['bicep', 'apiops']) param apiSurfaceOwner string = 'bicep' var bicepOwnsApiSurface = apiSurfaceOwner == 'bicep' 

Every API-surface resource carries the condition:

resource ordersApiPolicy 'Microsoft.ApiManagement/service/apis/policies@2024-05-01' = if (bicepOwnsApiSurface) { parent: ordersApi name:Source:https://dev.to/martin_oehlert/apiops-for-azure-api-management-extract-review-and-publish-with-the-apiops-cli-1gl6

Related posts