Skip to content
Back to Blog
11 min read

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.
The boundary

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.

PR → PROVISION · ON MERGE TO MAIN Manifest (YAML) in main PR + validate gate schema + policy OIDC → token no stored secret Service principal provisions Fabric tenant ws · cap · roles Nightly drift job (cron) opens a GitHub issue on divergence reconcile via new PR provision on merge reconcile via PR · official Microsoft Fabric icons
Figure 1. The tenant-as-code loop. A manifest change is reviewed and validated, then on merge a federated (secret-free) service principal provisions the tenant. A scheduled drift job reconciles live state against the manifests and raises a tracked issue when they diverge. That closes the loop through another PR. The tenant box shows the target state; the Challenge 01 scripts manage workspaces, capacity and roles.

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 an owners list 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.yaml holds 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.py finds 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.yml runs on pull requests, provision.yml runs on merge to main behind a production environment approval, and drift.yml runs on a schedule to compare live state against the manifests. In Challenge 01, drift.py reports 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 providerScripts and YAML manifests
Drift detectionBuilt in: terraform plan diffs every resource in stateYou write and maintain each check
StateA state file to store, lock and protectNone; live tenant is compared to Git each run
Unmanaged objectsInvisible to plan unless importedA script can list everything and flag strays
PolicySeparate tooling (validation blocks, OPA, Sentinel)Policy is a reviewed YAML file the same scripts read
Reviewer skill neededHCL plus provider semantics and plan outputYAML 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:

scripts/publish_items.py
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:

src/items/parameter.yml
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) when Lakehouse isn’t in item_type_in_scope, but I wouldn’t rely on it.
  • Pre-created items. Keep Example_LH in the repo and Lakehouse in item_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, set is_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 production environment approval.
  • deploy: uses a separate app (FABRIC_DEPLOY_CLIENT_ID) with only Contributor on the target workspace and its own federated credential for environment: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: PPE and the PPE workspace ID, and make the PROD job depend on it.
  • The cost: because deploy needs provision and 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.
.github/workflows/fabric-deploy.yml
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.

Michael John Peña

Michael John Peña

Filipino-Australian Technologist based in Melbourne. Writing about Microsoft technologies and lately about AI/LLM