Fabric-CICD in Practice - Groundwork
Setting up Fabric CI/CD, laying the groundwork of branching strategy, workspaces, service principals, connections, pipelines and approval gates before a first test deployment
As a first for me I am going to try writing a series. Starting from an empty workspace, over three posts, we will build up to a multi-workspace solution that deploys into empty environments with nobody clicking anything. It will be opinionated, and the first opinion is that fabric-cicd is the only sensible basis for a complete CI/CD solution in Fabric. This first post is the groundwork the series will build on: the branching strategy and what each workspace is for, the sample repo, the service principals, the pipelines that promote code to higher environments, and the approval gate that protects prod, ending with a deployment into empty test and prod workspaces.
I've written about fabric-cicd a few times already; the series links those posts and doesn't re-explain them.
Previous Posts
- Fabric-cicd, Kicking the tyres (May 2025)
- One Pipeline to Rule Them All (June 2025)
- Fabric-CICD Updates - Semantic Models, Parameters and Config (February 2026)
- Fabric-CICD - It's Official (February 2026)
- CICD for Fabric Data Teams (May 2026)
- Lost Connections (August 2026)
Series - Fabric-CICD in Practice
- Groundwork
- Feature to Prod
- Two Workspaces, One Deploy
fabric-cicd v1.3.0
This series is pinned to fabric-cicd 1.3.0 and Fabric CLI v1.7.0.
Terminology
The series will use Azure DevOps as the CI/CD platform. A few terms come up in every post, so here they are once.
| Term | What it means here |
|---|---|
| Workspace | A Fabric workspace. One per environment per solution, named <solution>-<env>, so foo-dev |
| Repo | The Azure Repos git repository holding the solution: one folder per workspace under fabric/, plus the pipelines |
| Branch | main, release/* or feature/*. Each maps to a workspace; the Branching Strategy table says how |
| Connection | A Fabric connection to a data source, with its own owner and credential. Not part of any item definition |
| Azure Pipelines | The CI/CD runner in Azure DevOps. A pipeline is a YAML file in the repo made of stages, jobs and steps, triggered by a branch. Each job runs on an agent; this series uses Microsoft-hosted agents, a fresh Azure VM per job, discarded when it ends |
| Environment | An Azure DevOps object that a deployment job targets. Approvals and locks are attached to the environment, to guard deployment into it |
| Service connection | The Azure DevOps object that points at the identity a pipeline authenticates as (a service principal or managed identity) and says how. We will use workload identity federation, so there is no secret to store or rotate |
| Service principal (SPN) | The Entra identity the pipeline runs as. It needs a role on every workspace it deploys to and access to every connection the items use |
| Workload identity federation (WIF) | Azure DevOps issues a short-lived token for the service connection, the pipeline presents it to Entra, and Entra checks it against a federated credential on the app registration (issuer plus subject identifier, both generated by Azure DevOps) before issuing an access token for the SPN |
Fabric CLI (fab) |
Microsoft's command line for Fabric. fab deploy is a fabric-cicd config deployment, fab api is a raw REST call, fab import pushes one item definition, fab auth login is how the SPN signs in |
| fabric-cicd | Microsoft's Python library that publishes item definitions from a repo into a workspace |
config.yml |
fabric-cicd's deployment config: which folder to deploy, which workspace to deploy to per environment, which item types to deploy, and publish and unpublish rules |
parameter.yml |
fabric-cicd's rewrite rules, applied to definitions on the way into the workspace |
| Item definition | The files under an item's folder in git: the .platform file plus the item's own parts. What fabric-cicd publishes |
| Logical ID and object ID | The two kinds of identifier of a Fabric item. Logical IDs rebind across workspaces, object IDs don't. See Lost Connections |
Sample Repo
The code for this post is the post-1 tag of EvaluationContext/fabric-cicd-example. Each post in the series ends on a tag you can clone and run.
Setting the Stage¶
Who Is Using Fabric-CICD?¶
I don't believe that many people are using fabric-cicd. Now, I can't directly prove this statement, but I can get a proxy for it. Every pipeline run that installs fabric-cicd counts as a download, so downloads measure how often the tooling runs, not how many people run it. The number of distinct people who have ever opened an issue on the repo is closer to a headcount, because it needs a human who hit a problem and cared enough to write it up. Figures are from late September 2026.
| Repo | Issues | Distinct authors | Excluding maintainers | Raised one issue only | Stars |
|---|---|---|---|---|---|
| fabric-cicd | 625 | 265 | 261 | 169 | 331 |
| terraform-provider-fabric | 301 | 131 | 127 | 97 | 128 |
| fabric-cli | 90 | 55 | 55 | 39 | 172 |
fabric-cicd was downloaded from PyPI about 264,000 times in the last month, and the Terraform provider shows 2.7 million registry downloads in total.
With 261 people who have raised an issue, most of whom raised exactly one, and 331 stars over nearly two years, it feels fair to say only a few hundred teams are actively using fabric-cicd.
The point of this series is to try and make fabric-cicd more accessible and easier to adopt for everyone.
Setting The Bar¶
This solution will have to pass the following bar:
- Cold start: the solution must be able to deploy Fabric Items into completely empty workspaces from the repository alone
- No clicks: the solution must not require any manual intervention outside of the repository, beyond the initial setup
- Idempotent: the solution must produce the same result regardless of how many times it is applied
To limit the scope of this series, we will not tackle:
- Environment setup: automated creation and configuration of workspaces, permissions, connections etc
- Bootstrapping: running notebooks or pipelines to populate a lakehouse, a dacpac to set up the initial database schema, semantic model refreshes etc
- Data ops: what data non-production environments hold, and how it gets there
- Connection switching: pointing each environment at a different source system. One connection serves every environment in this series
Why fabric-cicd¶
Microsoft's recommended path is git integration for source control, deployment pipelines for promotion and variable libraries for the per-environment values. I don't like it, for two reasons, and Lost Connections has the detail on both.
-
Greenfield: none of the three can stand an environment up from the repo alone, because each needs the target item to exist before it can be pointed at.
-
Clicks: deployment rules, active value sets and branch-out are all UI steps someone has to remember, and I believe if you do something more than once you should automate it.
fabric-cicd rewrites item definitions during deployment and resolves ids against the workspace being deployed to, at publish time, so a first deploy into an empty workspace works. It isn't the whole answer, as Jacob Knightley described in a post last year; there are some limitations around multi-workspace deployments. But it is still the best deployment solution available.
Two native features survive. Auto-binding, where the binding matrix says it applies, because there's no reason to write a parameter.yml rule for something Fabric already does. And git integration, but only for feature workspaces and only in one direction: changes made in the workspace are committed to git, never the other way around.
That git connection is also why the series uses Azure DevOps rather than GitHub. Connecting a workspace to GitHub needs a Personal Access Token (PAT), which belongs to a person, expires, and takes the connection with it when they leave. That might be tolerable for short-lived feature workspaces, but connecting to Azure DevOps can authenticate as a service principal (SPN), so nothing in the chain depends on a personal account, and I'd rather not build on an exception.
Deploying With the Fabric CLI¶
fabric-cicd is a Python library, and most write-ups, including my earlier ones, call it from a Python script: build a FabricWorkspace, call publish_all_items(), pass a parameter file. In this series I'm not doing that. Since v0.1.26 fabric-cicd has had configuration-based deployment, which I covered in Fabric-CICD Updates, and the Fabric CLI ships it as fab deploy. The whole deployment is a login and one line:
fab auth login -u "$servicePrincipalId" --federated-token "$idToken" --tenant "$tenantId"
fab deploy --config fabric/foo/config.yml --target_env dev
I like this for two reasons. Everything about the deployment, which workspace each environment is, which item types are in scope, what gets unpublished, lives in config.yml next to the items it deploys and is reviewed in the same pull request. Nobody has to read a script or open a variable group to find out which workspace prod is. And fab is one tool for the whole job: fab deploy publishes, and fab api calls any Fabric REST endpoint when something has to happen after the publish, like a semantic model refresh, without a hand-rolled HTTP call.
Hosted agents
fab encrypts its token cache with the operating system keyring, and a hosted build agent has none, so a login in one step is gone by the next. Run fab config set encryption_fallback_enabled true before fab auth login, on every agent, every run.
Branching Strategy¶
In this series I'm adopting Microsoft's Release Flow as is, because it's a simple, modified trunk-based strategy, and Azure DevOps is built around it. main is the trunk and is always deployable. Work happens on short-lived feature/* branches that merge to main by pull request. When a release is ready, a release/* branch is cut from main and never merges back. A hotfix lands on main first and is cherry-picked into the release branch.
%%{init: {
'theme': 'base',
'gitGraph': { 'showBranches': true, 'showCommitLabel': true, 'mainBranchName': 'main' },
'themeVariables': {
'git0': '#db2777', 'git1': '#f472b6', 'git2': '#f59e0b', 'git3': '#fb7185',
'gitBranchLabel0': '#ffffff', 'gitBranchLabel1': '#4a044e', 'gitBranchLabel2': '#4a044e', 'gitBranchLabel3': '#4a044e',
'gitInv0': '#4a044e', 'gitInv1': '#4a044e', 'gitInv2': '#4a044e', 'gitInv3': '#4a044e',
'commitLabelColor': '#4a044e', 'commitLabelBackground': '#fce7f3', 'commitLabelFontSize': '12px'
}
}}%%
gitGraph
commit id: "initial"
branch feature/sales-report
checkout feature/sales-report
commit id: "add report"
commit id: "fix measure"
checkout main
merge feature/sales-report id: "PR merged: deploy dev"
branch release/2026.10
checkout release/2026.10
commit id: "cut: deploy test, approve, prod" type: HIGHLIGHT
checkout main
branch feature/hotfix
checkout feature/hotfix
commit id: "hotfix"
checkout main
merge feature/hotfix id: "PR merged: deploy dev again"
checkout release/2026.10
cherry-pick id: "hotfix"
checkout main
commit id: "next feature"
Each branch maps to a workspace, and each workspace is there for a reason. The reason is what decides what gets deployed to it, by whom, and when; without it we'd just be following a paradigm.
| Workspace | Branch | Why it exists | Deployed by | Git connected |
|---|---|---|---|---|
feature |
feature/* |
A branch's sandbox, so a developer can change anything without touching anyone else's work. Disposable; torn down when the branch merges | Git sync | To the feature branch, commits out only |
dev |
main |
Proves that what has merged to main works together. Nothing lands here except by pipeline, so if dev is broken, main is broken |
fabric-cicd, on every merge | No |
test |
release/* |
Proves the prod deployment will land. It gets the exact commit prod gets, and it's where user acceptance happens, which may mean prod-shaped data | fabric-cicd, on every push to the release branch | No |
prod |
release/* |
The users' workspace. Nobody deploys here except the release pipeline, after an approval | fabric-cicd, after the approval | No |
flowchart LR
subgraph Git Branches
F[feature/*]
M[main]
R[release/*]
end
subgraph Fabric Workspaces
FW[feature]
D[dev]
T[test]
P[prod]
end
F -- "provision, then git connect" --> FW
FW -- "commit" --> F
F -- "pull request" --> M
M -- "fab deploy on merge" --> D
M -- "cut release" --> R
R -- "fab deploy" --> T
R -- "fab deploy, after approval" --> P
I want a feature workspace to be provisioned by a pipeline when the branch is created, filled by a deployment to resolve item dependencies, then attach git integration, with a git sync from workspace branch, and torn down when the branch merges. The git integration sync is only applied one way, workspace branch: a developer commits from the workspace to the repo, and nothing is pulled from git into the workspace. Definitions edited in VS Code or by an agent go the other way, one item at a time, with fab import followed by a read-back, the pattern Microsoft's skills-for-fabric uses. One item, one call, one error that names the item, and no "try again" commits on the branch, just to facilitate the use of git sync.
Dev, test and prod are never git-connected. The pipeline is the only thing that publishes them, and there is nothing a git connection would add except a source control panel showing drift after every deploy, because parameter.yml rewrote ids on the way in.
Repo Structure¶
The repo will be structured as follows. A single workspace foo with five items: a lakehouse, a pipeline that copies a file into it from an external source and then runs a notebook, the notebook that writes the table, a Direct Lake semantic model on the lakehouse's SQL analytics endpoint, and a report. Azure pipelines are defined in .azure-pipelines/.
├── 📁 .azure-pipelines
│ ├── 📄 variables.yml // (1)!
│ ├── 📄 deploy-main.yml // (2)!
│ ├── 📄 deploy-release.yml // (3)!
│ └── 📁 templates
│ ├── 📄 fab-setup-steps.yml // (4)!
│ └── 📄 fab-deploy-steps.yml // (5)!
├── 📁 fabric
│ └── 📁 foo // (6)!
│ ├── 📁 Sales.Lakehouse
│ ├── 📁 Load Sales.DataPipeline // (7)!
│ ├── 📁 Load Sales.Notebook // (8)!
│ ├── 📁 Sales.SemanticModel // (9)!
│ ├── 📁 Sales.Report // (10)!
│ ├── 📄 config.yml // (11)!
│ └── 📄 parameter.yml // (12)!
├── 📄 .gitignore
└── 📄 README.md
- Service connection names - the only values to edit to run the pipelines
- main to dev - every merge to
mainpublishes the dev workspace - release/* to test to prod - test on every push, a manual approval, then prod
- Setup steps - install the Fabric CLI and log in as the SPN with workload identity federation
- Deploy steps -
fab deployone environment and publish the log as a build artifact - fabric-cicd
repository_directory- one folder per workspace - Data pipeline - copies
sales.csvfrom an external source over a connection, then runs the notebook. Holds the notebook's logical id, a zero workspace id and a connection id - Notebook - writes the
salestable. Holds the default lakehouse's logical id and a zero workspace id - Semantic model - Direct Lake on the lakehouse's SQL analytics endpoint. Holds the endpoint host and id as literals
- Report - bound to the model by path. Holds nothing that needs rewriting
- fabric-cicd
config.yml- environment to workspace name, item types in scope, publish and unpublish rules - fabric-cicd
parameter.yml- rewrites applied on the way in. Holds only the two semantic model rules at the end of this post
No warehouse
I've left it out on purpose. A warehouse deploys as an empty shell, because its REST API has no definition operations, so the schema has to be built and published separately with SqlPackage. That's a post of its own, not this series.
Lets Get Started¶
I won't cover creating the Azure DevOps organisation and project; the free tier gives five users, unlimited private repos and one hosted parallel job (new organisations have to request it), which is enough to follow along.
Most of what follows is done once for the tenant and the Azure DevOps project, and then never again. Only the last few steps repeat for each solution. It's worth knowing which is which before starting, because the one-time work needs an admin and the per-solution work shouldn't.
| Setup | Once per tenant or project | Once per solution (repo) |
|---|---|---|
Service principals sp-fabric-deploy-* and their service connections sc-fabric-deploy-* |
||
Security groups sg-fabric-deploy-* |
||
| Tenant settings scoped to those groups | ||
Azure DevOps environments fabric-dev, fabric-test, fabric-prod and the approval on prod |
||
The repo, config.yml, parameter.yml and the two pipeline files |
||
Workspaces foo-dev, foo-test, foo-prod on a capacity, with the SPNs as Contributor |
||
| Connections the items use, shared with both SPNs | ||
| Registering the two pipelines and authorising them on the service connections |
Service Principals¶
Lets start with the service principals: the Entra identities that run the pipelines and deploy the Fabric items.
To help protect against an accidental deploy to production, we will use two identities, one for non-prod and one for prod environments.
| SPN | Security group | Service connection | Used by | dev |
test |
prod |
|---|---|---|---|---|---|---|
sp-fabric-deploy-nonprod |
sg-fabric-deploy-nonprod |
sc-fabric-deploy-nonprod |
deploy-main.yml, and the Test stage of deploy-release.yml |
Contributor | Contributor | |
sp-fabric-deploy-prod |
sg-fabric-deploy-prod |
sc-fabric-deploy-prod |
The Prod stage of deploy-release.yml only |
Contributor |
Naming Convention
Names follow <type>-fabric-deploy-<env>, so the app registration sp-fabric-deploy-prod and its service connection sc-fabric-deploy-prod are visibly the same thing in two places, and the environment is always the last token. Each SPN sits in its own security group, sg-fabric-deploy-nonprod and sg-fabric-deploy-prod, because they each need different tenant settings (below).
flowchart LR
subgraph ADO["Azure DevOps"]
DM["deploy-main.yml"]
DR["deploy-release.yml"]
SCN["sc-fabric-deploy-nonprod"]
SCP["sc-fabric-deploy-prod"]
end
subgraph Entra
SPN1["sp-fabric-deploy-nonprod"]
SPN2["sp-fabric-deploy-prod"]
end
subgraph Fabric
D["foo-dev"]
T["foo-test"]
P["foo-prod"]
end
DM --> SCN
DR -- "Test stage" --> SCN
DR -- "Prod stage, after approval" --> SCP
SCN -- "WIF" --> SPN1
SCP -- "WIF" --> SPN2
SPN1 -- "Contributor" --> D
SPN1 -- "Contributor" --> T
SPN2 -- "Contributor" --> P
Workload Identity Federation¶
The quickest way to get an SPN with a federated credential is to let Azure DevOps create it. One dialog makes the app registration, adds the federated credential with the right issuer and subject, and saves the connection already verified. Do it for non-prod, then repeat for prod.
In Azure DevOps, Project settings Pipelines Service connections Create service connection Azure Resource Manager Next.
Identity type App registration (automatic), credential Workload identity federation. Scope level Subscription, and pick one. Service connection name sc-fabric-deploy-nonprod. Leave Grant access permission to all pipelines unticked. Save.
In Entra ID App registrations, find the app Azure DevOps just made. It's named after the organisation and project with a guid on the end (EvaluationContext-fabric-cicd-17d44053-c029-4b2b-9c34-595693fd79f5 in my case). Rename it sp-fabric-deploy-nonprod. The display name is cosmetic: the federated credential's subject identifier is built from the service connection name, so nothing breaks. Open Certificates & secrets Federated credentials and you'll see the trust Azure DevOps wrote. There is no client secret, and there never will be.
Service connection Contributor role
Azure DevOps also gave the new SPN Contributor on the scope, because that's what an Azure Resource Manager connection is for. Fabric doesn't need it. Remove the role assignment, or pick an empty resource group rather than the subscription as the scope when creating the connection.
With that in place a pipeline can authenticate without a secret: AzureCLI@2 with the service connection and addSpnToEnvironment: true, which puts the SPN's client id, the federated token and the tenant id into the step's environment, and fab auth login consumes them. No secret is read from anywhere, because there isn't one.
- task: AzureCLI@2
displayName: fab auth login (workload identity)
inputs:
azureSubscription: ${{ parameters.serviceConnection }}
scriptType: bash
scriptLocation: inlineScript
addSpnToEnvironment: true # exposes servicePrincipalId, idToken, tenantId
inlineScript: |
set -euo pipefail
fab auth login \
-u "$servicePrincipalId" \
--federated-token "$idToken" \
--tenant "$tenantId"
fab auth status
Security Groups¶
While we're in Entra, create the three security groups. Two hold an SPN each and scope its tenant settings; the third holds people, and is the approval gate on prod.
| Group | Members | Used for |
|---|---|---|
sg-fabric-deploy-nonprod |
sp-fabric-deploy-nonprod |
Tenant settings for the non-prod SPN |
sg-fabric-deploy-prod |
sp-fabric-deploy-prod |
Tenant settings for the prod SPN |
sg-fabric-approve-prod |
The people allowed to put a release into production | The approval check on the fabric-prod environment |
In Entra ID Add Group.
Group type Security, the group name, an owner, and the members: the matching SPN for the two deploy groups, the release approvers for the third. Create.
Tenant Settings¶
Following least privilege, tenant settings should be scoped to security groups, never the whole tenant. The two SPNs need different sets. Prod only ever publishes into a workspace that already exists, so it gets the API setting and nothing else. Non-prod also creates feature workspaces and connects them to git, so it gets the other two.
| Tenant setting | sg-fabric-deploy-nonprod |
sg-fabric-deploy-prod |
|---|---|---|
| Service principals can use Fabric APIs | ||
| Service principals can create workspaces, connections, and deployment pipelines | ||
| Users can synchronize workspace items with their Git repositories |
Apply them from Tenant settings in the Fabric admin portal.
Fabric¶
Workspaces¶
Now we can create the workspaces for the three environments, on a capacity, under exactly the names config.yml will use.
| Environment | Workspace | Contents |
|---|---|---|
| Dev | foo-dev |
The five items, because that's where they were committed from |
| Test | foo-test |
Empty |
| Prod | foo-prod |
Empty |
Git Integration
None of them are connected via git integration. The pipeline is the only thing that publishes them.
Permissions¶
The SPNs need the Contributor role on the workspaces to deploy into them.
| Workspace | sp-fabric-deploy-nonprod |
sp-fabric-deploy-prod |
|---|---|---|
foo-dev |
Contributor | |
foo-test |
Contributor | |
foo-prod |
Contributor |
Connections¶
Connections are not in any item definition and never travel with a deploy. A connection has two identities attached: the one that owns it and the one it authenticates with. For authentication my order of preference is:
- A workspace identity, where the item type supports it. It's owned by the workspace, not by a person. Give user access as needed.
- An SPN-owned connection, created by an SPN so it isn't tied to anyone's account. Give user access as needed.
- A service account, only where neither of the above is possible.
- A personal account, only where none of the above is possible. It ties the connection to a person, and when they leave the organisation the connection goes with them.
Whichever you choose, the deploying SPN needs at least User access to the connection: either it created the connection, or the connection was shared with it before the deploy. Otherwise the pipeline item that references it fails to publish.
Deployment Configuration¶
With a lot of the plumbing set up we can finally look at the contents of the repo.
Repo Init¶
Create an empty repo in the Azure DevOps project. The first commit is the five items under fabric/foo/, and there are two ways to get them there. Connect an existing workspace to main with Git folder fabric/foo, commit, and disconnect again; dev is never git-connected once the pipeline owns it. Or call Bulk Export on the workspace, unzip into fabric/foo/, git init, commit and push, and nothing ever gets connected. Either way the folder is named after the workspace, and that pairing is deliberate. A repo can hold several workspace folders, each with its own items and config.
├── 📁 fabric
│ └── 📁 foo
│ ├── 📁 Sales.Lakehouse
│ ├── 📁 Load Sales.DataPipeline
│ ├── 📁 Load Sales.Notebook
│ ├── 📁 Sales.SemanticModel
│ └── 📁 Sales.Report
└── 📄 .gitignore
config.yml¶
We can start by adding the config.yml file to the workspace folder. This file defines the deployment configuration like what workspace maps to which environment (i.e. dev foo-dev) (by name or id), the relative path to the directory of items to deploy, item types in scope, publish rules per environment and the location of an optional parameter.yml file.
├── 📁 fabric
│ └── 📁 foo
│ ├── 📁 Sales.Lakehouse
│ ├── 📁 Load Sales.DataPipeline
│ ├── 📁 Load Sales.Notebook
│ ├── 📁 Sales.SemanticModel
│ ├── 📁 Sales.Report
+│ └── 📄 config.yml
└── 📄 .gitignore
core:
workspace:
dev: foo-dev
test: foo-test
prod: foo-prod
repository_directory: "."
item_types_in_scope:
- Lakehouse
- Notebook
- DataPipeline
- SemanticModel
- Report
parameter: "parameter.yml"
publish:
skip:
dev: false # merge to main deploys dev
test: false # release/* deploys test before prod
prod: false
unpublish:
skip:
dev: false # dev mirrors main: orphaned items are removed
test: false
prod: false # prod deletes on rollback too
parameter.yml¶
Now we can add the parameter.yml file to the workspace folder. This file will hold any environment-specific parameterization rules that will update item definitions during deployment.
├── 📁 fabric
│ └── 📁 foo
│ ├── 📁 Sales.Lakehouse
│ ├── 📁 Load Sales.DataPipeline
│ ├── 📁 Load Sales.Notebook
│ ├── 📁 Sales.SemanticModel
│ ├── 📁 Sales.Report
│ ├── 📄 config.yml
+│ └── 📄 parameter.yml
└── 📄 .gitignore
We've already hit our first auto-binding gap. The semantic model links to the Lakehouse's SQL endpoint, which doesn't support auto-binding. With these two rules a deploy binds the semantic model to the target environment's lakehouse.
find_replace:
- find_value: 'Sql\.Database\("([^"]+)",'
is_regex: "true"
replace_value:
_ALL_: "$items.Lakehouse.Sales.$sqlendpoint"
item_type: "SemanticModel"
item_name: "Sales"
file_path: "/Sales.SemanticModel/definition/expressions.tmdl"
- find_value: 'Sql\.Database\("[^"]+",\s*"([0-9a-fA-F-]{36})"\)'
is_regex: "true"
replace_value:
_ALL_: "$items.Lakehouse.Sales.$sqlendpointid"
item_type: "SemanticModel"
item_name: "Sales"
file_path: "/Sales.SemanticModel/definition/expressions.tmdl"
Pipelines¶
Environments and Approvals¶
Now we can set up our Azure DevOps environments. We navigate to Pipelines Environments to begin.
Select Create environment, name it, and repeat until there are three: fabric-dev, fabric-test and fabric-prod, with no resources. On fabric-prod, Approvals and checks Approvals, add sg-fabric-approve-prod as the approver, and add an Exclusive lock so two releases can't deploy to prod at once. Dev and test get nothing.
That approval is the only human step in the flow. This means after creating a release/* branch, the deploy-release.yml pipeline will run, deploying to Test, but it will pause for the manual approval from sg-fabric-approve-prod before deploying to the fabric-prod environment.
Two Pipelines¶
We will have two pipelines: deploy-main.yml for deploying changes to the main branch (dev) and deploy-release.yml for deploying release branches (test and prod). deploy-main.yml triggers when changes in fabric/ are detected on main. deploy-release.yml triggers on any branch matching release/*.
Each of these pipelines references the two shared templates: fab-setup-steps.yml (install fab, the encryption fallback, the federated login) and fab-deploy-steps.yml (fab deploy). variables.yml holds the two service connection names and nothing else.
+├── 📁 .azure-pipelines
+│ ├── 📁 templates
+│ │ ├── 📄 fab-setup-steps.yml
+│ │ └── 📄 fab-deploy-steps.yml
+│ ├── 📄 variables.yml
+│ ├── 📄 deploy-main.yml
+│ └── 📄 deploy-release.yml
├── 📁 fabric
│ └── 📁 foo
│ ├── 📁 Sales.Lakehouse
│ ├── 📁 Load Sales.DataPipeline
│ ├── 📁 Load Sales.Notebook
│ ├── 📁 Sales.SemanticModel
│ ├── 📁 Sales.Report
│ ├── 📄 config.yml
│ └── 📄 parameter.yml
└── 📄 .gitignore
trigger:
# Two merges a minute apart must not publish dev concurrently. The second
# waits and runs once for everything queued behind the first.
batch: true
branches:
include:
- main
paths:
include:
- fabric/foo/
pr: none
variables:
- template: variables.yml
stages:
- stage: Dev
displayName: Deploy to Dev
jobs:
- deployment: DeployDev
displayName: fab deploy (dev)
pool:
vmImage: ubuntu-latest
environment: fabric-dev
strategy:
runOnce:
deploy:
steps:
- checkout: self
- template: templates/fab-setup-steps.yml
parameters:
serviceConnection: ${{ variables.serviceConnectionDev }}
- template: templates/fab-deploy-steps.yml
parameters:
targetEnv: dev
trigger:
branches:
include:
- release/*
pr: none
variables:
- template: variables.yml
stages:
# ---------------------------------------------------------------------------
# TEST: every push to a release branch
# ---------------------------------------------------------------------------
- stage: Test
displayName: Deploy to Test
jobs:
- deployment: DeployTest
displayName: fab deploy (test)
pool:
vmImage: ubuntu-latest
environment: fabric-test
strategy:
runOnce:
deploy:
steps:
- checkout: self
- template: templates/fab-setup-steps.yml
parameters:
serviceConnection: ${{ variables.serviceConnectionDev }}
- template: templates/fab-deploy-steps.yml
parameters:
targetEnv: test
# ---------------------------------------------------------------------------
# PROD: approve, deploy
# ---------------------------------------------------------------------------
- stage: Prod
displayName: Deploy to Prod
dependsOn: Test
jobs:
- deployment: DeployProd
displayName: fab deploy (prod)
pool:
vmImage: ubuntu-latest
environment: fabric-prod # the manual-approval check lives here
strategy:
runOnce:
deploy:
steps:
- checkout: self
- template: templates/fab-setup-steps.yml
parameters:
serviceConnection: ${{ variables.serviceConnectionProd }}
- template: templates/fab-deploy-steps.yml
parameters:
targetEnv: prod
parameters:
- name: targetEnv
displayName: Target environment key in config.yml (dev / test / prod)
type: string
- name: configFile
displayName: Path to the fabric-cicd config.yml
type: string
default: fabric/foo/config.yml
steps:
- script: |
set -euo pipefail
config="${{ parameters.configFile }}"
env="${{ parameters.targetEnv }}"
if [ ! -f "$config" ]; then
echo "##vso[task.logissue type=error]Config file '$config' does not exist."
exit 1
fi
mkdir -p "$(Build.ArtifactStagingDirectory)/deploy-log"
log="$(Build.ArtifactStagingDirectory)/deploy-log/${env}-$(Build.BuildId).log"
echo "##vso[task.setvariable variable=fabDeployLogWritten]true"
{
echo "fab deploy --config $config --target_env $env --force"
echo "commit: $(Build.SourceVersion)"
echo "branch: $(Build.SourceBranch)"
echo "run: $(Build.BuildNumber)"
echo "=================================================================="
} | tee "$log"
# pipefail is on, so a failing deploy still fails the step despite tee.
# --force: no interactive confirmation on an agent.
fab deploy --config "$config" --target_env "$env" --force 2>&1 | tee -a "$log"
displayName: fab deploy (${{ parameters.targetEnv }})
# Published whenever the deploy step wrote a log, on failure too, which is
# when it matters.
- publish: $(Build.ArtifactStagingDirectory)/deploy-log
artifact: deploy-log-${{ parameters.targetEnv }}
displayName: Publish deployment log (${{ parameters.targetEnv }})
condition: eq(variables['fabDeployLogWritten'], 'true')
# Install the Fabric CLI and log in as the SPN. Every job runs these first.
parameters:
- name: serviceConnection
displayName: WIF service connection name
type: string
- name: pythonVersion
displayName: Python version for the agent
type: string
default: '3.12'
steps:
- task: UsePythonVersion@0
displayName: Use Python ${{ parameters.pythonVersion }}
inputs:
versionSpec: ${{ parameters.pythonVersion }}
# Nothing is pinned: every run installs the current ms-fabric-cli, which
# brings the current fabric-cicd with it. A deploy can therefore change
# behaviour with no commit behind it, so the versions are echoed into the
# log and a breakage can be tied to a release. A pin is one line here if
# that trade-off flips. The series is written against fabric-cicd 1.3.0.
- script: |
set -euo pipefail
python -m pip install --upgrade pip
python -m pip install ms-fabric-cli
echo "== versions =="
python -m pip show ms-fabric-cli fabric-cicd | grep -E '^(Name|Version):'
displayName: Install Fabric CLI (current)
# Hosted agents have no keyring, so fab can't encrypt its token cache and
# will otherwise fail to persist the login between steps.
- script: |
set -euo pipefail
fab config set encryption_fallback_enabled true
displayName: fab config (hosted-agent token cache)
- task: AzureCLI@2
displayName: fab auth login (workload identity)
inputs:
azureSubscription: ${{ parameters.serviceConnection }}
scriptType: bash
scriptLocation: inlineScript
addSpnToEnvironment: true # exposes servicePrincipalId / idToken / tenantId
inlineScript: |
set -euo pipefail
fab auth login \
-u "$servicePrincipalId" \
--federated-token "$idToken" \
--tenant "$tenantId"
fab auth status
Template Repo
In a real scenario, you would have a repo for the templates (fab-deploy-steps.yml and fab-setup-steps.yml), where you would pin a version of Fabric-CLI, and tag the template repository. This allows you to set up thin pipeline definitions in your Fabric Items repos that would reference the tagged version of the templates rather than the latest commit. This allows you to centralize reused code and control when updates to the templates are adopted, rather than automatically getting the latest changes.
Once the pipelines are committed to Azure DevOps, they will not run until they are registered. Go to Pipelines Create pipeline Azure Repos Git select the repo Existing Azure Pipelines YAML file, once each for deploy-main.yml and deploy-release.yml, Save and name them deploy-main and deploy-release.
Now that the pipelines exist, go back to each service connection and select Security.
Select Pipeline permissions, and authorise them: deploy-main and deploy-release on sc-fabric-deploy-nonprod, deploy-release alone on sc-fabric-deploy-prod. Nothing else can use them, and the first run of each pipeline will prompt for exactly this if you forget.
Testing the Deployment¶
foo-dev already holds the five items, because that's where they were committed from, so a merge to main proves the pipeline rather than the deploy: deploy-main runs, logs in as sp-fabric-deploy-nonprod, and republishes five items over themselves. Green, and nothing visibly changes.
The real test is the release pipeline, into an empty workspace. Cut release/2026.10 from main. deploy-release is triggered and deploys test, and five items appear in an empty foo-test.
The run stops at the approval on fabric-prod; only a member of sg-fabric-approve-prod can let it through. Approve, and foo-prod fills the same way.
The workspace lineage view confirms that the items in foo-prod are bound to each other, not to dev.
Conclusion¶
Two service principals with no secrets, three workspaces, a repo, two pipelines and one approval, and nobody clicked anything to get code into production. Up to the point where the report opens, the overview's promise holds. What it doesn't say is that "deployed" and "works" are different questions. The next post follows a feature from a branch to prod.














