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:

Prerequisites

Before you can deploy Power BI via CI you need:

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:

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:

ResourceRole
Dev workspaceMember or Admin
Staging workspaceMember or Admin
Prod workspaceAdmin (for XMLA deploys)
Tenant: Service Principals Can Use Power BI APIsEnabled
Tenant: Service Principals Can Use Fabric APIsEnabled

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

SymptomRoot cause
Deploy fails with XMLA errorXMLA read-write not enabled; requires Premium / PPU / Fabric capacity
Service principal cannot deployNot added to the workspace as Member+
Staging and prod driftManual edits in the service; disable "Users can edit in service"
Deploy succeeds but refresh failsConnection credentials or gateway misconfigured per env; test refresh post-deploy
BPA violations merge inNo PR gate; add the Tabular Editor CLI step

See also