Fabric Item CI/CD Is Mostly Solved. Your Tenant Isn't
Getting notebooks, pipelines and semantic models from Git into a Fabric workspace is mostly a solved problem, with a few sharp edges left. Microsoft documents four workflow patterns for it, and the fabric-cicd Python library reached 1.0 on 20 April 2026. What most teams still haven’t answered is where the tenant itself is defined: who created that workspace, which capacity and domain it sits in, who holds Admin, and which tenant setting someone flipped last quarter. Git integration doesn’t manage any of that, and that’s where Fabric platforms drift.
I covered the item pipeline itself back in Fabric CI/CD: Building Deployment Pipelines. This post is about the layer around it.
Four ways to ship items, one gap
Microsoft Learn’s CI/CD workflow options article describes four patterns and says real deployments often mix them:
| Option | Source of truth | Branching model | Deployment mechanism | Per-stage config | Fits |
|---|---|---|---|---|---|
| 1 — Git integration | Git | Gitflow (a primary branch per stage) | Fabric Git APIs (update-from-git) |
Separate branches | Teams who want Git as the only origin and no definition transforms before deploy |
| 2 — Fabric Items APIs | Git (single Main) | Trunk-based | Fabric Items APIs (fabric-cicd or bulk import) | Build-environment scripts | Teams who must rewrite IDs or connections before deploy |
| 3 — Deployment pipelines | Fabric workspace (Git only through dev) | Trunk-based | Deployment pipelines APIs | Deployment rules and autobinding | Fabric-native, low-code promotion |
| 4 — CI/CD for ISVs | Git (single Main) | Trunk-based | Fabric Items APIs (per customer workspace) | Per-customer release parameters | ISVs with hundreds of customer workspaces |
In option 3 the source of truth after dev is a Fabric workspace, not your repo. The pipeline, its stage assignments and any workspace it creates for an empty stage are tenant-layer objects, so the manifests should own them: pre-create the stage workspaces rather than letting a deploy create them with default settings.
All four options move item definitions into workspaces; none of them governs the workspaces themselves. Here is what sits outside the item pipeline:
- Workspace lifecycle. You connect Git to a workspace. Nothing in the repo creates one, enforces a naming convention or retires it.
- Capacity and domain. Assigning a workspace to a capacity needs workspace Admin plus contributor rights on the capacity. Assigning it to a domain through the admin Domains APIs needs Fabric Administrator (check each API’s supported identities before automating it with a service principal); domain admins and contributors can also do it in the portal.
- Tenant settings. Publish to web, service principal access, delegation to capacity admins. These live in the admin portal and admin APIs, not a workspace repo.
- Connections and gateways. fabric-cicd’s own item type notes say connections aren’t source controlled and must be created separately.
- Workspace roles. Who is Admin, Member, Contributor or Viewer isn’t part of an item definition.
Git integration versions item definitions. Everything that makes those items usable in production — the workspace itself, its capacity and domain, its connections, and who can touch it — lives outside Git unless you put it there.
Even inside the item layer, Microsoft’s page on cross-workspace dependency binding warns that items which store dependencies as workspace-specific object IDs, rather than logical IDs, stay pointed at the source workspace after deployment, which breaks it. The item pipeline assumes a governed platform around it, and I’d rather that platform be built by pull request than by portal click.
A reference blueprint for the tenant layer
Microsoft has published a workshop-style repo that takes this on directly: microsoft/frontier-fabric-governance-rvas. Its principle is the one I’d adopt: nothing exists in the tenant unless a YAML manifest for it lives in main, was reviewed through a pull request and was provisioned by a service principal through GitHub Actions.
The pieces are worth copying even if you never run the repo:
- Manifests. One YAML file per workspace under
workspaces/, carrying name, capacity, region, domain, sub-domain, sensitivity label, cost centre and anownerslist of principals and roles. - A schema. A JSON Schema enforces the naming pattern
<country>-<area>-<subject>-<dataProductType>-<env>-<suffix>and at least two owners. - A policy file.
rules/policy.yamlholds approved capacities, domains, sensitivity labels, cost centres and per-group quotas, plus rules such as “at least one owner is a Group” and “at least one owner is Admin”. Its header says to edit it “to evolve governance without changing scripts”, the right separation. - An idempotent provisioner.
provision.pyfinds a workspace by display name, creates it if it’s missing, sets the description and capacity, and adds any missing role assignments. Domain and sensitivity-label assignment come in a later challenge, not this script. It deliberately never removes assignments it doesn’t recognise, with the comment “do NOT remove unknown to avoid locking ourselves out”. - Three workflows.
validate.ymlruns on pull requests,provision.ymlruns on merge tomainbehind aproductionenvironment approval, anddrift.ymlruns on a schedule to compare live state against the manifests. In Challenge 01,drift.pyreports only unmanaged workspaces, missing workspaces and description mismatches. Capacity, domain and role drift are checks you add yourself.
That additive role behaviour is a conscious trade-off: safe, but a manifest can’t revoke access granted by hand. If you adopt it, make drift detection report unexpected role assignments and remove them through a reviewed change.
Why not the Terraform provider?
Microsoft’s CI/CD overview suggests the Terraform provider for Microsoft Fabric for workspaces and capacities, with fabric-cicd for items. GA since March 2025, it covers fabric_workspace (including its capacity_id), fabric_workspace_role_assignment, fabric_domain, fabric_deployment_pipeline and, in preview, fabric_domain_workspace_assignments.
| Terraform provider | Scripts and YAML manifests | |
|---|---|---|
| Drift detection | Built in: terraform plan diffs every resource in state | You write and maintain each check |
| State | A state file to store, lock and protect | None; live tenant is compared to Git each run |
| Unmanaged objects | Invisible to plan unless imported | A script can list everything and flag strays |
| Policy | Separate tooling (validation blocks, OPA, Sentinel) | Policy is a reviewed YAML file the same scripts read |
| Reviewer skill needed | HCL plus provider semantics and plan output | YAML plus the policy file |
I’d pick Terraform if your platform team already runs it for Azure: you get plan-based drift and one state model across capacity, Entra groups and workspaces. I’d pick manifests when the people requesting workspaces aren’t infrastructure engineers, when governance rules should read as policy rather than code, or when you don’t want another state file to protect.
Treat the repo as a blueprint, not a product: adapt the workshop challenges before anything touches production.
Designing the service principals
Split read from write
Fabric has two Admin API tenant settings for service principals, each scoped to its own security group (enable service principal admin APIs):
- Service principals can access read-only admin APIs covers the read-only admin APIs.
- Service principals can access admin APIs used for updates covers Fabric admin APIs that change state, such as Restore Workspace.
The app behind either setting must not have any admin-consent-required Fabric permissions in Entra ID.
My rule of thumb is two identities: a read identity in the first group for inventory, reporting and drift detection, and a write identity in the second group used only by the provisioning job.
The write identity also needs two Developer settings, scoped to its own group: Service principals can create workspaces, connections, and deployment pipelines and Service principals can call Fabric public APIs. Create Workspace, Assign to Capacity and Add Workspace Role Assignment are non-admin APIs gated by those settings, so without them workspace creation and role grants fail. The item-deploy identity also needs to be in the group for Service principals can call Fabric public APIs (it doesn’t need the create-workspaces setting or any admin setting).
Be careful with tenant settings as code. Listing tenant settings is a stable admin API, but the Update Tenant Setting API is still in preview, is rate-limited to 25 requests a minute, and needs the delegated Tenant.ReadWrite.All scope for a user caller; a service principal instead needs to be in the group for Service principals can access admin APIs used for updates, with no Fabric API permissions on the app. I’d version the desired settings in Git and alert on drift with the list API, and automate writes only once you trust a preview API with your most privileged configuration.
No stored secret
The blueprint uses one app registration, gh-fabric-workspace-provisioner, with three federated credentials matched to GitHub OIDC subjects: pull_request for read-only checks, ref:refs/heads/main for drift and environment:production for writes after approval, all with audience api://AzureADTokenExchange. GitHub swaps a short-lived OIDC token for an Entra token at run time, so there’s no client secret to rotate or leak.
It also grants that identity Fabric Administrator, Capacity Admin on each in-scope capacity and membership of the sensitivity-label publishing scope, while humans can’t push to main. That’s a lot of power, defensible only because nothing drives it except reviewed merges and an environment gate. If your branch protection is weak, split the identity further.
Per-stage values without hardcoding
The failure I see most often when a Fabric deployment works in dev and breaks in prod is a hardcoded ID. Fabric gives you three ways to inject them.
Variable libraries are generally available. A variable library holds variables with multiple value sets, one of which is active per workspace, and pipelines, notebooks, Dataflow Gen2, Copy job and shortcuts can consume them. This is my default for anything a running item needs to know about its environment.
Deployment rules apply to option 3. Data source, parameter and default lakehouse rules are set on the target stage and take effect on the next deployment.
parameter.yml is fabric-cicd’s build-time transform for option 2. fabric-cicd 1.0 made token_credential a required, keyword-only argument and dropped the DefaultAzureCredential fallback, so pass a credential explicitly. In a GitHub Actions job that’s already signed in with azure/login, AzureCliCredential picks up that session:
import os
from azure.identity import AzureCliCredential
from fabric_cicd import FabricWorkspace, publish_all_items, unpublish_all_orphan_items
target_workspace = FabricWorkspace(
workspace_id=os.environ["TARGET_WORKSPACE_ID"],
repository_directory="src/items",
item_type_in_scope=["Lakehouse", "Notebook", "DataPipeline", "Environment", "VariableLibrary"],
environment=os.environ["TARGET_ENVIRONMENT"],
token_credential=AzureCliCredential(),
)
publish_all_items(target_workspace)
unpublish_all_orphan_items(target_workspace)
item_type_in_scope accepts fabric-cicd’s supported item type names, with exact casing; leave it out to deploy everything. The environment value selects which key in parameter.yml applies:
find_replace:
# Dev lakehouse GUID referenced by notebooks
- find_value: "<dev-lakehouse-guid>"
replace_value:
_ALL_: "$items.Lakehouse.Example_LH.$id"
item_type: "Notebook"
key_value_replace:
- find_key: $.properties.activities[?(@.name=="Run Notebook")].typeProperties.notebookId
replace_value:
_ALL_: "$items.Notebook.Hello World.$id"
item_type: "DataPipeline"
$items.<type>.<name>.$id resolves to the item’s ID in the target workspace at deploy time, so one value works for every stage, and the _ALL_ key applies it to whichever environment the job passes. Swap _ALL_ for per-stage keys (PPE, PROD) once the values need to diverge; _ALL_ must be the only key when you use it. A few resolution rules are worth knowing:
- Scope. The parameterization guide only guarantees resolution for items in
repository_directory. Current releases also resolve a lakehouse created by a bootstrap step (the blueprint’s medallion-bootstrap challenge) whenLakehouseisn’t initem_type_in_scope, but I wouldn’t rely on it. - Pre-created items. Keep
Example_LHin the repo andLakehouseinitem_type_in_scope, as the script above does, or reference a pre-created item explicitly with$workspace.<name>.$items.Lakehouse.Example_LH.$id. - Regex. For a regex
find_value, setis_regex: "true"and wrap the value to replace in a capture group.
By default fabric-cicd runs a full deployment rather than diffing commits (1.0 added get_changed_items() for opt-in selective deploys). The full run is what makes it converge, and it’s also why a hotfix typed into the prod portal disappears on the next run.
Wiring it into GitHub Actions
The workflow below validates on every pull request and, on merge, provisions the tenant layer before publishing items. Only the two jobs that sign in get an OIDC grant; the pull request job runs with read-only repository access. scripts/validate.py and scripts/provision.py stand in for your own manifest checks and idempotent provisioner.
- provision: runs as the Fabric Administrator write identity behind the
productionenvironment approval. - deploy: uses a separate app (
FABRIC_DEPLOY_CLIENT_ID) with only Contributor on the target workspace and its own federated credential forenvironment:prod-items, then runs the publish script above. The two environments make the double approval deliberate: tenant changes and item releases are signed off separately. - PPE: add a copy of the deploy job with its own environment,
TARGET_ENVIRONMENT: PPEand the PPE workspace ID, and make the PROD job depend on it. - The cost: because
deployneedsprovisionand both share one path filter, every item release also re-runs the idempotent provisioner and needs a tenant approval. If that’s too much friction, split it into two workflows by path.
name: fabric-deploy
on:
pull_request:
paths: ["workspaces/**", "rules/**", "src/items/**"]
push:
branches: [main]
paths: ["workspaces/**", "rules/**", "src/items/**"]
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install pyyaml jsonschema
- name: Validate manifests against schema and policy
run: python scripts/validate.py
provision:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: validate
runs-on: ubuntu-latest
environment: production # required reviewers configured on the environment
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: actions/checkout@v6
- uses: azure/login@v3
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install -r scripts/requirements.txt
- name: Provision workspaces, capacity and roles from manifests
run: python scripts/provision.py
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [validate, provision]
runs-on: ubuntu-latest
environment: prod-items # separate approval for item releases
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: azure/login@v3
with:
client-id: ${{ vars.FABRIC_DEPLOY_CLIENT_ID }} # Contributor on the target workspace only
tenant-id: ${{ vars.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install "fabric-cicd~=1.2" azure-identity
- name: Publish item definitions
env:
TARGET_WORKSPACE_ID: ${{ vars.PROD_WORKSPACE_ID }}
TARGET_ENVIRONMENT: PROD
run: python scripts/publish_items.py
Where this isn’t worth it
Tenant-as-code costs real effort: schema, policy, provisioner, drift job, separate identities and the discipline to stop clicking in the portal. For a single team with three workspaces and one capacity, I’d skip it; a runbook and the audit log are enough.
It pays off once many teams request workspaces, when naming and ownership rules exist only on paper, or when an auditor asks who approved a tenant setting change. If you’re there, start small: manifests and a validate workflow first, provisioning second, drift third, tenant settings last. My earlier post on Fabric tenant settings is a reasonable list of which settings deserve to be in that repo.
The test I’d apply
You have tenant-as-code when every workspace, capacity assignment, domain and role grant traces back to a merged pull request; no person holds standing write access to production; the identity that does write has no secret; per-stage values come from variable libraries, deployment rules or parameter.yml; and a scheduled job tells you when reality has moved. Item CI/CD gives you repeatable deployments. The tenant layer makes the platform reviewable, and Git integration was never going to do that for you.