Treating Power BI semantic models as code means shipping them like code: a CI pipeline, a service-principal identity, a BPA gate, a promoted artifact per environment. This guide walks through the 2026 canonical pipeline.
The tools
Three production-grade tools, distinct roles:
- fabric-cicd: Microsoft-published Python library. Deploys PBIP artifacts to Fabric workspaces. The 2026 default.
- Tabular Editor CLI: Best Practice Analyzer validation, TMDL manipulation, XMLA deploys.
- ALM Toolkit CLI: compares two tabular models, generates selective deployment scripts for surgical prod fixes.
Prerequisites
Before you can deploy Power BI via CI you need:
- PBIP format for every semantic model (see PBIP guide).
- Three workspaces: dev, staging, prod. All have XMLA read-write enabled, which requires Premium, Premium Per User, or Fabric capacity.
- A service principal with:
- Entra ID app registration.
- Service Principal Can Use Fabric APIs tenant setting enabled.
- Added to all three workspaces as Member or Admin.
- Tenant setting: Users can edit semantic models in the Power BI service disabled for prod. Manual service-side edits are a drift source.
The canonical pipeline
PR opened (feature → main)
├─ git checkout
├─ Tabular Editor CLI: run BPA rules → fail on violations
├─ fabric-cicd: validate PBIP artifacts
├─ fabric-cicd deploy → dev workspace
├─ DAX query tests (Tabular Editor CLI) against dev model
└─ (optional) Playwright visual regression screenshot diffs
merge to main
├─ fabric-cicd deploy → staging workspace
├─ Enhanced Refresh: refresh staging model; check for errors
├─ integration tests (DAX query assertions)
└─ fabric-cicd deploy → prod workspace (service principal)
1. The fabric-cicd config
config.yml:
workspaces:
dev:
id: "00000000-0000-0000-0000-000000000001"
staging:
id: "00000000-0000-0000-0000-000000000002"
prod:
id: "00000000-0000-0000-0000-000000000003"
items:
- name: sales_model
type: SemanticModel
path: ./sales_model.SemanticModel
- name: sales_report
type: Report
path: ./sales_report.Report
environment_parameters:
dev:
- name: databricks_warehouse_id
value: "abc123"
staging:
- name: databricks_warehouse_id
value: "def456"
prod:
- name: databricks_warehouse_id
value: "ghi789"
Variables substitute per environment at deploy time. Hardcoded workspace IDs in TMDL are banned; read-from-config is the discipline.
2. The GitHub Actions workflow
# .github/workflows/pbi-deploy.yml
name: Power BI Deploy
on:
pull_request:
paths: ['**.pbip', '**.SemanticModel/**', '**.Report/**']
push:
branches: [main]
jobs:
validate:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Run BPA rules
shell: pwsh
run: |
./tools/TabularEditor.exe `
./sales_model.SemanticModel `
-A "./tools/BPA Rules.json" -V
- name: Install fabric-cicd
run: pip install fabric-cicd
- name: Validate PBIP
run: fabric-cicd validate --config config.yml
deploy-dev:
needs: validate
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- run: pip install fabric-cicd
- name: Azure OIDC login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to dev
run: fabric-cicd deploy --target dev --config config.yml
deploy-prod:
needs: validate
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- run: pip install fabric-cicd
- name: Azure OIDC login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to staging
run: fabric-cicd deploy --target staging --config config.yml
- name: Smoke-test refresh
run: ./tools/trigger_refresh.sh --workspace staging --model sales_model
- name: Deploy to prod
run: fabric-cicd deploy --target prod --config config.yml
- name: Smoke-test prod refresh
run: ./tools/trigger_refresh.sh --workspace prod --model sales_model
Important
Authenticate to Azure via workload identity federation (OIDC), not a service principal secret. GitHub Actions' OIDC integration lets the workflow prove its identity to Azure without storing a secret anywhere. This is the same pattern as Databricks bundles in CI.
3. BPA as a PR gate
Tabular Editor's Best Practice Analyzer is where most model-quality enforcement lives. The PR gate:
# validate.ps1
./tools/TabularEditor.exe `
./sales_model.SemanticModel `
-A "./tools/BPA Rules.json" `
-V
if ($LASTEXITCODE -ne 0) {
Write-Error "BPA violations found. Fix before merging."
exit 1
}
Ship a BPA Rules.json in ./tools/ that includes:
- Microsoft's default BPA rule set.
- Org-specific rules (naming, required descriptions, banned DAX).
- Causeway-specific rules (e.g., every measure on a shared model must have a
description).
Warning
BPA violations found after deploy are too late. A model that passes fabric-cicd's syntactic validation can still be full of SUMX(FILTER(...)) patterns that will hammer your warehouse. Gate on BPA before anything else runs.
4. DAX query tests
Write DAX queries that assert expected totals for known periods. Run them after each deploy:
// tests/assert_revenue_2025_q1.dax
EVALUATE
ROW(
"Result",
VAR ExpectedRevenue = 12_345_678
VAR ActualRevenue = CALCULATE(
[Total Revenue],
'dim_date'[quarter] = "2025 Q1"
)
RETURN
IF(ActualRevenue = ExpectedRevenue,
"PASS",
"FAIL: expected " & ExpectedRevenue & ", got " & ActualRevenue)
)
Run via Tabular Editor CLI or the XMLA endpoint from a test harness. The test passes on "PASS", fails on anything else.
These tests catch measure-logic regressions that BPA cannot detect. BPA checks shape; DAX tests check semantics.
5. Post-deploy refresh test
Every deploy ends with an Enhanced Refresh invocation. A model that deploys but cannot refresh is worse than no deploy:
# trigger_refresh.py
import time, requests
def refresh_and_wait(workspace_id: str, dataset_id: str, token: str):
# Kick off refresh
start = requests.post(
f"https://api.powerbi.com/v1.0/myorg/groups/{workspace_id}"
f"/datasets/{dataset_id}/refreshes",
headers={"Authorization": f"Bearer {token}"},
json={"type": "full", "commitMode": "transactional"},
)
refresh_id = start.headers["Location"].split("/")[-1]
# Poll
while True:
time.sleep(15)
result = requests.get(
f"https://api.powerbi.com/v1.0/myorg/groups/{workspace_id}"
f"/datasets/{dataset_id}/refreshes/{refresh_id}",
headers={"Authorization": f"Bearer {token}"},
).json()
if result["status"] == "Completed":
return
if result["status"] == "Failed":
raise RuntimeError(f"Refresh failed: {result.get('messages')}")
6. Rollback
Every prod deploy tags the commit. Rollback is a deploy at the previous tag:
git checkout v2026.04.15
fabric-cicd deploy --target prod --config config.yml
For surgical fixes (one measure), use ALM Toolkit CLI to generate a selective deployment script instead of redeploying the whole model. This leaves visual authors' parallel PRs untouched.
7. Fabric Deployment Pipelines: yes or no?
Fabric Deployment Pipelines is the point-and-click promotion tool inside Fabric. Fine for small teams and non-technical authors.
For anything Git-backed and automated, use fabric-cicd + your CI platform. Deployment Pipelines do not integrate cleanly with PR-gated workflows, and you want BPA in a real pipeline runner rather than a wizard.
Tip
Teams that adopt Deployment Pipelines as their main promotion path consistently end up bolting on Git integration six months later and unpicking the pipeline wizard. Start with fabric-cicd; you will not regret it.
8. Service principal grants
The deploying service principal needs:
| Resource | Role |
|---|---|
| Dev workspace | Member or Admin |
| Staging workspace | Member or Admin |
| Prod workspace | Admin (for XMLA deploys) |
| Tenant: Service Principals Can Use Power BI APIs | Enabled |
| Tenant: Service Principals Can Use Fabric APIs | Enabled |
Nothing broader. Resist the "add it as a tenant admin just in case" pattern; the blast radius of a compromised deploy identity is the set of workspaces it can modify.
Common mistakes
| Symptom | Root cause |
|---|---|
| Deploy fails with XMLA error | XMLA read-write not enabled; requires Premium / PPU / Fabric capacity |
| Service principal cannot deploy | Not added to the workspace as Member+ |
| Staging and prod drift | Manual edits in the service; disable "Users can edit in service" |
| Deploy succeeds but refresh fails | Connection credentials or gateway misconfigured per env; test refresh post-deploy |
| BPA violations merge in | No PR gate; add the Tabular Editor CLI step |
See also
- PBIP + Git workflow — the source format fabric-cicd consumes.
- Enhanced Refresh — the post-deploy step.
- Model authoring standards — the rules BPA enforces.