Treating a Power BI report like a control: version control, review, and CI
Putting a Power BI project under Git with .pbip, then adding review, CI checks, and an audit trail, so a report is versioned, reviewable, and auditable.
Commit a .pbix and the entire diff Git can show you is "the file changed." It
is a single binary blob, so two months later a reviewer reads "updated
report.pbix" and has no idea whether you fixed a relationship or repainted every
chart. That is why the report ends up on a shared drive, with its history in
filenames like report_final_v3_REALLY_final.pbix, and why the discipline every
other kind of code gets, the report just does not.
A readable diff is the entry price. What I want is a report that behaves like a software project: versioned, reviewed, tested, published on a repeatable path, and auditable. A report that produces a number someone acts on should carry the same controls as the number's source, and every one of those controls is something Git and GitHub already know how to do. Before I built software full time I spent years in broker-dealer supervision, where the whole job is making sure a change to something that matters was reviewed, evidenced, and traceable. This is the same discipline, pointed at a report, and the result is what I think of as a change-control register: a record of every edit to a report, who made it, who reviewed it, and what a machine checked before it shipped.
Synthetic data. Seeded generator, no real company.
The register, at a glance
Every change to this report passes through five kinds of control before it reaches production: a review, a CI gate, a controlled publish step, an audit trail, and a check on the sample data behind it. Here is how many distinct checks live at each stage, counted straight from the sections below.
- Five stages, twenty control points total: review (4), CI (6), publish (3), the audit trail (3), and data safety (4).
- CI carries the most because six of those control points live there, and they add up to two independent gates on every pull request: a model linter and a value assertion on the one number the business would actually notice if it moved. The other four are what make those two gates run without anyone starting them.
- None of the twenty depend on someone remembering. They trigger on a pull request or a merge, not on a calendar reminder.
The format that makes all of it possible
In Power BI Desktop, Save As offers a Power BI Project (.pbip) format
alongside the usual .pbix. Instead of one binary, it writes a small .pbip
pointer plus two folders of source-control-friendly files:
- PBIR stores the report definition, the pages, visuals, filters, bookmarks, and field assignments, as structured files. A diff shows "added an executive-summary page", not "the binary changed".
- TMDL stores the semantic model, the tables, columns, measures, relationships, and roles, as readable text. A diff shows the exact measure you added or the relationship you corrected.
Everything below depends on that one change. Once the report is text, the rest is just software engineering.
- Watch out: the project format has spent time behind Power BI Desktop's Preview features toggle, so on some versions you enable it under Options before Save As will offer it. Confirm any team permits it before making it the required production format.
Review: a change gets a second set of eyes
Text means pull requests, and pull requests mean the thing a report change has almost never had: review before it ships.
- A measure edit lands on a branch and opens a PR. A reviewer reads the TMDL
diff, sees that
Gross Margin %changed from dividing by revenue to dividing by net revenue, and asks the one question that matters before it is live. - Branch protection can require that review on
main. A change to a financial measure does not merge without an approval. That is a control, not a nicety. git blameon a measure answers "who last touched this calculation, and in which change", and a bad edit reverts in one command instead of a frantic hunt for last week's file.- Two people work on branches and merge, instead of emailing
.pbixfiles and overwriting each other. Report merges are not free, PBIR can conflict on visual layout, but the model TMDL merges cleanly enough that parallel work stops meaning lost work.
CI: quality gates that run on every pull request
Once the model is text, a machine can check it, so I do not have to remember
to. Two gates run on every pull request, and either one can fail the build
before a problem reaches main.
- Triggers on every pull request, before a change reaches
main. - Runs on a Windows runner, because the Power BI tooling is Windows-first.
- Installs the Tabular Editor 2 command-line runner, a free tool that ships with a large, extensible rule set.
- Points it at the semantic model definition with a Best Practice
Analyzer rule file, which flags:
- measures with no format string
- columns nothing references
- implicit measures
- inconsistent naming
- relationships on the wrong cardinality
- Fails the build on any non-zero exit, so a rule violation blocks the merge instead of reaching production.
- Runs a second gate, closer to a unit test: a measure-value assertion that
Gross Margin %still evaluates to 42.6% for January 2026 on a seeded dataset, so "trust me, the number is right" becomes a test that fails loudly the moment someone changes the calculation.
Tools I would use: the Tabular Editor 2 free CLI, GitHub Actions, and a Best Practice Analyzer rule file checked into the repo alongside the model.
Publish: a canned, repeatable release
The last manual step in most Power BI shops is a person opening Desktop and clicking Publish. Under version control I can retire it.
- A GitHub Action pushes the project to the Power BI Service on merge to
main, authenticated as a service principal, so "release the report" isgit merge, not a click nobody logged. - The same parameterized project promotes through dev, test, and prod workspaces, with the data-source connection swapped per environment by a parameter instead of by hand.
- The publish surface itself (the Fabric and Power BI REST APIs, service-principal permissions, deployment pipelines) is exactly the part that changes release to release, so I confirm the current mechanism against Microsoft's docs before wiring it. The principle, a deterministic publish triggered by a merge, is what lasts.
The audit trail
Everything above produces a side effect that, in a regulated setting, is the main effect. The history of the report is now a complete, attributable record:
- Who changed the revenue calculation, when, and why (the commit message, the PR discussion, and the issue it links to).
- The required review that approved it, preserved as evidence rather than a memory of a hallway conversation.
- Traceability from a requirement, to the change that implemented it, to the release that shipped it.
Why this helps the business
Ask a typical BI team to prove that a number on a board has not been quietly edited, and to show who signed off on the last change to it, and the usual answer is a shrug. This workflow changes what the answer looks like:
- A challenged number gets a link to a diff, a PR, and an approver, so "who changed this and why" has an answer months later, not a guess.
- The required review is evidence, not a memory of a hallway conversation, so it survives staff turnover and holds up under scrutiny.
- It is the same evidence trail a compliance supervisor looks for behind any regulated change, applied to a report instead of a trade or an account.
- A report built this way is a controlled report: provable on request, not just believed.
Reproducible data, not customer data
The hardest part of publishing analytics work is the data. Real records cannot go in a public repo, and a screenshot of an empty model proves nothing. The answer is to commit a small seeded generator instead of the data itself: a short Python script that invents a synthetic field-service company from a fixed random seed, so anyone who runs it gets byte-identical CSVs, and every number in a report built on it can trace back to data a reviewer can regenerate. A second script validates that data the way a real pipeline would, then exits non-zero if anything fails. What it checks:
- referential integrity
- contiguous dates
- duplicate keys
- a reconciliation tie-out
That pair does two jobs at once. It keeps real data out of the repo entirely, and it turns "trust me, the numbers are right" into a script a reviewer can run. A validation script like this is itself part of the evidence: it shows I think about data quality, not just visuals.
What to commit, and what never to
Most of a project belongs in Git: the artifact itself, and all of it is diff-able text or safe data. A short list never does.
| Commit to Git | Never commit |
|---|---|
The .pbip file | The analysis cache (cache.abf) |
| The PBIR report folder | Local UI state (.pbi/localSettings.json) |
| The TMDL model folder | Credentials and .env files |
| Documentation and a data dictionary | Production data exports |
| The seeded generator and validation scripts | Any screenshot with a real name, email, dollar figure, or address |
| Architecture notes, sanitized screenshots, and release notes |
Only the first two rows on the right have a .gitignore rule behind them. The
last one, a customer's name in the corner of a screenshot, is not something
any pattern catches. Reading git status and the staged diff before every
commit is the real control, and I treat that read as part of the commit, not
an optional extra.
Sample change-control register
What this looks like in practice, in the shape of a register a reviewer could scan in thirty seconds:
| Change | Reviewer flag | CI gate | Outcome |
|---|---|---|---|
Gross Margin % measure edited to divide by net revenue instead of revenue | Reviewer asked why the denominator changed, before approving | Measure-value assertion failed: no longer evaluates to 42.6% for January 2026 | Blocked. Denominator reverted, PR re-run, then merged |
| New relationship added between the fact table and a Type 2 dimension | Reviewer flagged a many-to-many cardinality in the TMDL diff | Best Practice Analyzer failed the cardinality rule | Blocked. Relationship corrected to one-to-many before merge |
| New report page summarizing revenue by region | No flag. PBIR-only change, no model impact | Both gates passed. No measure touched | Merged, then promoted to production on the next release |
Illustrative register. Only the first row is this post's own example; the other two show the same pattern on changes this post does not otherwise walk through.
If you are on Microsoft Fabric
This does not have to be the .pbip-in-a-folder approach at all. Microsoft Fabric
added native Git integration: a Fabric workspace syncs directly to a GitHub or
Azure DevOps repository, and its deployment pipelines promote between stages
without a hand-built publish job. If you are on Fabric, that is the first-class
path and worth preferring over rolling your own. It is also the fastest-moving
piece of this whole story, so treat any specific menu or API as something to
verify against the current docs rather than a fixed fact.
My process on the job
- I save every report as
.pbipfrom day one and treat.pbixas disposable, something Desktop can regenerate, never the thing I hand off. - I require a pull request and at least one approval for any change to a
published semantic model, with branch protection on
mainso that is not optional. - I wire a GitHub Action to run the Best Practice Analyzer plus a small set of measure-value assertions on every pull request, so a broken model fails before it reaches production.
- I keep a written measure dictionary next to the model and treat any mismatch between the dictionary and the DAX as a bug, not a documentation lag.
- I commit a seeded synthetic dataset and a validation script instead of asking for access to production data.
- I read
git statusand the staged diff by hand before every commit, because no ignore rule catches a customer's name inside a screenshot. - I promote the same parameterized project through dev, test, and prod workspaces on merge, so release is a deploy step, not a person clicking Publish.
Key takeaways
- Saving as
.pbipturns a binary report into diff-able PBIR and TMDL text, and that one change is what makes a change-control register possible at all. - Text means pull requests: a change gets a review before it ships, and
branch protection can require that review on
main. - A GitHub Action runs two gates on every pull request, a model linter and a measure-value assertion, so a machine enforces quality instead of me remembering to.
- The audit trail is the real deliverable: a complete, attributable record of who changed a number, when, why, and who approved it.
- I commit a seeded generator and a validation script instead of real data,
and I read
git statusbefore every commit so no customer data slips in.
Related posts
Other walkthroughs built on the same invented field-service company:
- The star schema and DAX measure dictionary that make the report trustworthy in the first place.
- Tracing gross margin from raw record to dashboard, reconciling one number at every layer.
- Six enterprise SQL patterns for dedup, idempotent loads, and a Type 2 dimension.
- The monthly gross-margin bridge report with bronze, silver, and gold layers and an idempotent MERGE.
- Machine learning on the same lakehouse: a legible completion model and a customer segmentation.