Repository navigation
Automate Power BI release packaging and fix template generation bugs - #2322
Open
Michael Flanakin (flanakin) wants to merge 52 commits into
Open
Michael Flanakin (flanakin) wants to merge 52 commits into
Michael Flanakin (flanakin) wants to merge 52 commits into
Conversation
Power BI releases ship three files: PowerBI-kql.zip and PowerBI-storage.zip (PBIT templates) and PowerBI-demo.zip (demo PBIX files). Only the first two were generated, and the last two releases stalled figuring out where the scripts stopped. Build-PowerBI now prunes each report's model once and renders it twice: a PBIT template and a self-contained PBIP project in release/pbix. Because both come from the same prune, the template and the demo report can't drift, and the manual "remove unused queries" step is gone. Templates ship with the data source parameters nulled; the projects keep the demo values so demo reports still refresh. Package-PowerBI is a new resumable command that works out which steps are done, does the next one, and validates the saved PBIX files. Saving from Power BI Desktop is the only manual step left. Fixes found along the way: - Build-PowerBI was broken on dev. A case-duplicate column added to AdvisorReservationRecommendations in #1849 made the model fail to load, and the script reported errors but still exited 0 with truncated PBIT files. Tables no report ships are now dropped before the model loads, so an unused broken table can't block a release, and the script fails loudly. - The data source nulling regex expected an escaped quote that was never there, so every shipped template had the demo storage account and cluster prefilled. - PBI_QueryOrder was double JSON encoded and leaked literal [storage] and [kql] prefixes into the query pane order. - DiagramLayout was never pruned, so templates shipped diagram entries for tables the model no longer had. - The Governance report listed PolicyDefinitions under Tables, but it's a query, so it was silently dropped from the template. - Package-Toolkit -Build -CopyFiles deleted the Power BI ZIP files that -Build had just generated. - -ZipPBI never created PowerBI-demo.zip, and its cleanup called Compress-Archive on a directory, which nests every part one folder deep and produces an unreadable PBIX. Confirmed unused: v14 ships the raw PBIX files. Removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
microsoft-github-policy-service
Bot
requested a review
from Brett Wilson (MSBrett)
September 12, 2026 05:08
Adds Save-PowerBIProject, which drives Power BI Desktop through UI Automation to open a generated project, refresh it through Desktop's local Analysis Services engine, apply the sensitivity label, save it as PBIX, and close. Package-PowerBI -Unattended runs it for every demo report, then validates and packages, so the whole Power BI release is one command on Windows. Failures name the step and save a screenshot, and -Open is still there as the manual fallback. Moves the per-report table and query allowlist to src/power-bi/reports.json so the build and the tests read the same list, and adds checks that fail with the report, page, visual, and what to add: - A visual on a visible page reads from a table the report doesn't keep. Hidden pages warn instead. - A query, table, or measure the report keeps uses a table or query the report doesn't keep. A new PowerBIReports lint test runs these checks in pull request CI, so a mismatch fails the PR that causes it instead of surfacing at release time. Build-PowerBI.Tests.ps1 covers pruning, TMDL parsing, name conflicts, dependency and reference detection, and every PBIX validation rule without needing the Analysis Services library or Power BI Desktop. Fixes found by the new checks, five of which shipped in v14: - The Governance report's Virtual machines page read from VirtualMachinesDisks, which the allowlist removed, so the disks table has been broken since #1647. - AdvisorReservationRecommendations declared Region and region, which a tabular model can't hold, so Build-PowerBI failed on dev. Both models now have one region column that coalesces either casing from Azure Resource Graph. - A storage relationship referenced AdvisorReservationRecommendations.subscriptionId, a column that never existed, so the full storage model couldn't load. - The data source nulling regex expected an escaped quote that was never there, so every shipped template had the demo storage account and cluster prefilled. - PBI_QueryOrder was double JSON encoded and leaked literal [storage] and [kql] prefixes. - DiagramLayout was never pruned, so templates listed tables the model no longer had. - Governance listed PolicyDefinitions under tables, but it's a query, so it was dropped from the template. - Package-Toolkit -Build -CopyFiles deleted the Power BI ZIP files that -Build had just generated. - -ZipPBI never created PowerBI-demo.zip, and its cleanup nested every PBIX part one folder deep. Confirmed unused (v14 ships the raw PBIX files) and removed. Build-PowerBI also reported errors but exited 0 with truncated PBIT files. It now fails loudly, and drops tables no report ships before loading the model so an unused broken table can't block a release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…abe7' into xe/powerbi-release-automation-b9abe7 # Conflicts: # docs-mslearn/toolkit/changelog.md
The demo hub was hardcoded in both semantic models, so a release connected to whatever the source project was last saved with. The clusters now point at a demohub instance that is no longer reachable. Demo projects are stamped at build time from reports.json (demo.storageUrl, demo.clusterUrl), which now point at ftk-dev.westus and devhubjrcckugyiacvk. The source models are updated to match so opening them still works. Templates continue to ship with the data source set to null. ftk_DemoFilter limited the demo to seven subscriptions only when the storage account matched a hardcoded list of account names, so it silently stopped filtering whenever the demo hub changed. Demo projects now get the subscriptions from demo.subscriptionIds instead, and the build warns when the list is empty that every subscription in the hub ships in PowerBI-demo.zip. The list starts empty because the previous subscriptions belong to the old hub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts the demo subscription filter rewrite. ftk_DemoFilter ships exactly as it is defined in the semantic model, and reports.json no longer carries a subscription list. The demo data source (demo.storageUrl, demo.clusterUrl) is still stamped into demo projects at build time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…meout Nothing in Save-PowerBIProject can sign in or answer a credential prompt, so a sign-in window meant waiting the full timeout before failing. Sign-in windows are now detected while waiting for the report to open, and the error says to sign in and refresh a project by hand first. Documents that Power BI Desktop has to be signed in with the demo data source credentials already stored, and that a report saved without a sensitivity label passes validation while a report saved with the wrong label fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The open data tables read from releases/latest/download, which resolves to the last published release. Any open data file added during the current release doesn't exist there yet, so the demo refresh fails with a 404. That's what happened to InstanceSizeFlexibility.csv, which ships in v15 but not v14. Demo projects now read open data from demo.openDataUrl (the repo) instead. Templates keep the release URL, so nothing changes for customers. Demo-only edits are now recorded and undone after the project is written. The template is rendered from the same in-memory model, so without this the demo open data URL leaked into the template. When a refresh fails, each table is refreshed on its own to find the cause. The engine reports what went wrong but not where, and a single failure cancels the whole transaction, so every table looked equally broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes the build-time data source stamping. The semantic models hold the demo hub as their default data source, so there is one place to change it instead of two, and reports.json no longer has a demo section. Demo projects read open data from src/open-data in the working tree instead of a branch URL, so packaging uses the branch being released. The release URL resolves to the last published release, which doesn't have open data files added during the release being built. Templates keep the release URL. A sign-in prompt now prints "ACTION NEEDED" and waits instead of failing, because Power BI Desktop pauses for a person and the console never said so. Unattended packaging also says up front that sign-in and credential prompts are the only steps that need someone at the keyboard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Storage reports failed to refresh against FOCUS 1.0r2 exports with "The 'CapacityReservationId' column does not exist in the rowset". Those columns are new in FOCUS 1.2, and the Costs query assumed they were always present. They're now created as empty columns when missing, using the same pattern that already handled AvailabilityZone for non-Microsoft clouds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Demo reports ship to customers, so reading open data from a local path would have broken refresh for anyone who opened them and leaked a local path. They now read from a public URL, set by -OpenDataUrl and defaulting to the main branch, which the release process merges to before packaging Power BI. Every open data URL is checked while building, so a file that isn't published yet fails with the file name and the fix instead of failing later when someone refreshes the demo. Templates are unchanged and keep the release URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Demo reports are saved with data from a demo hub, so that hub has to be on the
version being released and have data for the current month. There was no way
to deploy or check it, and Deploy-Toolkit -Demo hardcodes a resource group in
whatever subscription happens to be current.
Deploy-Demo deploys two kinds of instances in an explicit subscription:
- ftk-demo, the current demo hub with 12 months of data.
- ftk-demo-v{version}, a versioned instance with one month of data for opening
an older release. Its Data Explorer cluster can be stopped once data is
ingested, so keeping one per release costs almost nothing.
-Check reports the hub version and the months of data, and fails when the hub
is behind the toolkit version or has no data for the current month. That's now
a release checklist step, so a stale demo hub is caught before packaging
instead of halfway through a refresh.
Not yet run against Azure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The open data URL ships inside the demo reports, so a branch URL works while building and breaks for customers as soon as the branch is deleted. Only the main branch and release URLs are accepted now. Building against a branch needs -TestOpenDataUrl, which warns, records it in the manifest, and makes Package-PowerBI refuse to package the result, so a test build can't be released by mistake. Deploy-Demo no longer backfills on every run. Managed exports keep running on their own, so history is only loaded when -Backfill is specified, which a new instance needs once. Retention is now its own parameter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Power BI Desktop asks for data source credentials the first time it reads a source, including anonymous ones like a file on GitHub. The refresh call blocks until someone answers, so the command looked hung with no explanation. The refresh now runs on its own thread while the main one watches for prompts and names each as it appears. Detection also covers the anonymous web credential dialog and any dialog offering a Connect or Sign in button, not just sign-in titles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refreshing through the engine leaves calculated columns, tables, and relationships stale, which is what Power BI Desktop's "calculated objects need to be manually refreshed" banner reports. Saving in that state would ship a demo report with empty calculated columns. A calculate pass now runs after the data refresh, so nothing has to be refreshed by hand before saving. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applying the label threw "Exception calling Expand" and lost a report that had already spent seven minutes refreshing. A control can report a UI Automation pattern and still refuse it, so each pattern is now tried in turn before falling back to a real click. A label that still can't be applied warns instead of failing. Saving is the expensive part, and the label is checked when the saved file is validated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tenants without sensitivity labels still show the Sensitivity button, greyed out, so finding it wasn't enough to know a label could be applied. The control is now checked for being enabled, and the step is skipped when it isn't. -SensitivityLabel can also be set to an empty string to skip it outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The recalculate pass ran on the main thread, so it could neither report a credential prompt nor give up at the deadline. Both engine commands now run through one watched path that reports prompts and times out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Saving failed with "Could not find the file name box" after a report had already refreshed for eight minutes. The lookup only accepted AutomationId 1001, which isn't guaranteed. It now falls back to the box under the file name control and then to the only editable box in the dialog, and the Save button falls back to its name. When a control still can't be found, what the dialog contains is written next to the PBIX so the next attempt doesn't have to guess. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Power BI Desktop shows a "Refresh now" banner for the whole time an engine refresh runs, because it isn't driving the refresh, which makes a working refresh look hung. The command now reports how many tables the engine has finished every 30 seconds, so progress is visible without waiting it out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A refresh is one transaction, so every table stays NoData until it commits. Counting loaded tables would have reported no progress for the entire refresh and looked exactly like the hang it was meant to rule out. Progress now reports what the engine process has consumed, which moves while the refresh runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Save as dialog's search box and address bar are editable, so falling back to "the first editable box" typed the full path into the search box. The dialog then rejected it for containing \ and :, and the save was lost after the report had already refreshed for eight minutes. Boxes are now tried in order, starting with the one named File name, with the search box and address bar excluded, and the value is read back to confirm the right box kept it. The file type list also falls back to its label. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dialog opens in the folder the project was opened from, so a full path was unnecessary and its separators are rejected in a file name. A rejected name is now reported with what the dialog said instead of waiting it out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing only the file name is only safe when the dialog is already in the destination folder. The address bar is read to confirm it, and the full path is used when it isn't or can't be read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Power BI Desktop has other dialogs of the same window class, including Open. Matching on class alone typed the file name into one of those, and its default button merged folders instead of saving. The dialog must now have a Save button and no Open button. Builds also remove a folder named like a PBIX, which an older build left behind and which blocks saving a file with that name, and saving fails early when one is still there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requiring a Save button missed the dialog entirely, because the control isn't always a plain button, and every report timed out after refreshing for eight minutes. The title now decides, a Save control is only used for a dialog with no title, and ignored dialogs are named in verbose output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The file name box is a legacy control exposed as a pane, so it has no value to set and every candidate was skipped. It's now focused and typed into the way a person would, and the name is read back from the control either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading back which box took the file name doesn't work: these are legacy controls exposed as panes, with no value to read and a name that doesn't follow what was typed. Each box is now typed into and Save is clicked until the file shows up on disk, with any complaint dismissed and logged in between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A background window can't take focus, so SetFocus threw and the file name was never typed. The dialog is now brought to the front first, and a focus failure no longer skips the box, since typing reaches the focused control anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Windows refuses to give a background window focus, so SetFocus threw and nothing was ever typed. The file name is now written straight to the control's window and the Save button is clicked the same way, neither of which needs focus or the dialog to be in front. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding the native methods failed in any session that had run an older copy of this script, because a loaded type keeps the members it was compiled with. The type is only added when it isn't already there, and its name carries its shape so an older one is never mistaken for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The file type list isn't in what UI Automation reports for this dialog, so it stayed on Power BI project and Desktop wrote CostSummary.storage.pbix.pbip with .Report and .SemanticModel folders. The save looked like it worked right up until the PBIX wasn't there. The dialog's child windows are now walked directly to find the list, select the PBIX entry, and tell the dialog the selection changed so it rewrites the extension. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SendMessageString had no EntryPoint, so it matched no function in user32 and reading the file type entries would have failed at runtime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The child window list came back as a single object, so the loop ran once with the whole list and tried to use it as a window handle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
UI Automation doesn't report the file type list for this dialog, so the failure dump said nothing about the control that has to change. The dump now includes every child window with its class, id, and text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EnumChildWindows takes a callback, which doesn't survive the trip from PowerShell, so nothing was ever enumerated and the file type list was never found. Children are now walked one at a time with FindWindowEx, and the failure dump uses the handle captured while the dialog was still open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A session that already loaded the previous type keeps its methods, so adding FindWindowEx to it had no effect there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The recursive call returned its results as a single object, so descendants collapsed into one unusable entry and the file type list was never seen. The dialog is also recorded while it's still open, since clicking Save closes it before the failure is noticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dialog element reports no window handle, so nothing could be enumerated from it and the file type list was never found. The handle is now taken from the file name box, which has a working one, by walking up to the dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…follows Save as wrote CostSummary.storage.pbix.pbip and its folders because the file type stayed on Power BI project. The type combo is a real ComboBox, but four levels down (dialog > DirectUI view > DirectUIHWND > FloatNotifySink), past the depth the window walk covered. Enumerate the whole tree with a compiled EnumChildWindows callback instead, and skip the file name combo when looking for the type list. CB_SETCURSEL plus CBN_SELCHANGE to the dialog redraws the combo without changing what gets saved. Sending CBN_SELENDOK to the combo's own parent is what makes the dialog take the type and rewrite the extension, so that is what is sent now. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A report saved with the enhanced metadata format has no Report/Layout part, so validation reported a missing part without saying why it was missing. Templates are built in the old format and older Power BI Desktop versions can't open the new one, so the preview feature has to be off, which is now said where it fails and in the save requirements. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A type can't be redefined once loaded, so a session that had already run this script kept the methods it had then and ignored later edits, which showed up as missing methods and meant every change needed a new terminal. The helpers now compile under a name nothing has used yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PBIR has been generally available and the default report format since September 2026, and Power BI Desktop silently converts a report to it when saving, so demo reports can't be kept in the old format without running a Desktop release from before then. Validation now reads either format: the page a report opens on comes from pages.json and the page files, and the version the build stamped is looked for across the report definition. The guidance saying to turn PBIR off predates its general availability and is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Governance and WorkloadOptimization failed to refresh because the engine could
not load the Azure Resource Graph connector ("The import AzureResourceGraph.Query
matches no exports"), which leaves the tables that read from it empty. The same
refresh started from Power BI Desktop's Refresh button loads the connector and
completes, so a failed engine refresh is retried that way before the per-table
diagnosis. Desktop recalculates as part of its own refresh, so the separate
recalculate step is skipped in that case.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Say how long Package-PowerBI -Unattended takes, tell the release to rerun it once with -Build when a test open data build is left in release/pbix, and describe the fallback to Power BI Desktop's own Refresh when the engine can't refresh. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Roland Krummenacher (RolandKrummenacher)
left a comment
Collaborator
There was a problem hiding this comment.
Review notes on the new Power BI packaging scripts. The first five are the most likely to cause a bad or missing release artifact.
- Repackage after the unattended save loop and fail fast on unreleasable builds - Fail when a release ZIP is missing instead of reporting success - Keep the last good PBIX until a new save succeeds - Compare full paths for the Save as folder; tolerate an empty sensitivity label - Page through all ingestion blobs when checking demo hub data - Remove the manifest on partial builds so packaging rebuilds - Restore demo hub data source URLs; reword the lint rule in the README Co-Authored-By: RolandKrummenacher <RolandKrummenacher@users.noreply.github.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Roland Krummenacher (RolandKrummenacher)
approved these changes
Oct 13, 2026
Roland Krummenacher (RolandKrummenacher)
left a comment
Collaborator
There was a problem hiding this comment.
Thanks, all ten points are addressed and I checked each fix against the new commit. CI is green.
One non-blocking nit inline on the PBIX backup in Save-PowerBIProject.ps1. Fine as a follow-up.
…pted runs - Restore the backup in finally, after Power BI Desktop closes, so Ctrl+C and a locked file are covered - Restore a leftover backup when the PBIX is missing instead of deleting it 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: RolandKrummenacher <RolandKrummenacher@users.noreply.github.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Collaborator
Author
|
🤖 [AI][Claude Code] PR Update Summary Addressed: 2 thread(s)
The PBIX backup is now restored from |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 [AI]
🛠️ Description
Power BI releases ship three files:
PowerBI-kql.zip,PowerBI-storage.zip(PBIT templates) andPowerBI-demo.zip(demo PBIX files). Only the first two were generated, and the last two releases stalled working out where the scripts stopped.One command on Windows now does all of it:
Verified end to end: all 6 demo reports refresh, save, and pass validation (~1 hour total).
What changed
Build-PowerBIrelease/pbix. Template and demo report can't drift, and the manual "remove unused queries" step is gone.src/power-bi/reports.json(new)Save-PowerBIProject(new)Package-PowerBI-Unattendedsaves, validates, and packages. Reports that fail validation are saved again automatically.Deploy-Demo(new)-Checkfails when the hub is behind the toolkit version or has no current-month data. Not yet run against Azure.PowerBIReportslint test (new)Validation
A saved PBIX is rejected when it has no data, opens on the wrong page, came from the unpruned source project, still has version placeholders, predates the latest build, or carries the wrong sensitivity label. Both report formats are accepted:
Report/Layoutand PBIR (Report/definition), which became the default in September 2026.Bugs fixed
Five shipped in v14.
VirtualMachinesDisks, which the allowlist removedRegion/regioninAdvisorReservationRecommendations(#1849)Build-PowerBIwas broken ondev. Both models now have oneregioncolumn that takes either casing from ARGAdvisorReservationRecommendations.subscriptionId, a column that never existedPBI_QueryOrderdouble JSON encoded with literal[storage]/[kql]prefixesDiagramLayoutnever prunedPolicyDefinitionsunder tables, but it's a queryCostsassumed FOCUS 1.2 columns always existPackage-Toolkit -Build -CopyFilesdeleted the Power BI ZIPs-Buildhad just made-ZipPBInever createdPowerBI-demo.zip; its cleanup nested every PBIX part one folder deepBuild-PowerBIalso reported errors and exited 0 with truncated PBIT files. It now fails loudly.'type' column does not exist in the rowset). Desktop's own refresh succeeds, which is why the fallback exists.AzureResourceGraph.Query matches no exportsappeared intermittently — the ARG connector in Power BI Desktop, not packaging.RateOptimization.kqlhas a hidden page reading tables the report doesn't keep. Build warns; delete the page or keep the tables.🧪 Testing
66 Pester tests. Full
-Unattendedrun on Windows: all 6 demo reports refreshed, saved, and validated. Real packaging still needs the open data published onmain, since demo reports read open data over HTTP and the release URL resolves to the last published release.Contributes to #888.
📋 Checklist
🔬 How did you test this change?
📦 Deploy to test?
🙋♀️ Do any of the following that apply?
📑 Did you update
docs/changelog.md?📖 Did you update documentation?
🤖 Generated with Claude Code