
Your team just shipped a Canvas App that 400 field technicians depend on daily to log equipment inspections. It works beautifully in your development environment. Then someone — maybe you, maybe a well-meaning colleague — makes an "emergency fix" directly in production. Two weeks later, nobody can remember what changed, why the approval flow broke, or why the new version of the app overwrites the shared Business Units table that the Finance team's app also depends on. You now have a franken-environment that nobody fully understands, and you're one bad deployment away from a weekend incident.
This is the reality of Canvas Apps without Application Lifecycle Management (ALM). And it's shockingly common even in organizations that run disciplined DevOps practices everywhere else — because Power Platform looks low-code, so people assume it doesn't need grown-up engineering practices. It absolutely does. Solutions that span multiple Dataverse environments, shared component libraries, and cross-team dependencies require the same rigor you'd bring to a production microservice.
By the end of this lesson, you will be able to design and operate a full enterprise ALM pipeline for Canvas Apps: unpacking solutions into source-controllable files, building GitHub Actions workflows that automate deployment across environments, architecting solution layers that protect shared components from per-app changes, and making defensible architectural decisions about where to put the seams in your system. This is not a beginner lesson. We're going to get into the internals of the Power Platform solution file format, discuss where the tooling lies to you, and build something you could actually use at work Monday morning.
What you'll learn:
You should be comfortable with:
You'll need access to:
pac) installed locallyIf you don't have a service principal yet, pause here and set one up. Everything in this lesson assumes non-interactive authentication, because interactive authentication in a pipeline breaks at 3 AM during a scheduled deployment.
Before you can version-control anything, you need to understand what you're actually versioning. When you export a solution from Power Platform as a .zip file, you get a specific directory structure. The pac solution unpack command explodes that zip into a tree of XML and JSON files that Git can actually diff.
Let's look at what that tree looks like for a realistic solution called EquipmentInspections:
EquipmentInspections/
├── src/
│ ├── CanvasApps/
│ │ └── equipment_inspectionapp_ab123/
│ │ ├── equipment_inspectionapp_ab123.msapp ← binary, more on this
│ │ └── equipment_inspectionapp_ab123.meta.xml
│ ├── Workflows/
│ │ └── NotifyTechnicianOnOverdueInspection-{guid}.json
│ ├── Entities/
│ │ └── equipment_Inspection/
│ │ ├── Entity.xml
│ │ └── Attributes/
│ │ ├── equipment_completeddate.xml
│ │ └── equipment_technicianid.xml
│ ├── ConnectionReferences/
│ │ └── equipment_SharedOffice365Connection.json
│ ├── EnvironmentVariableDefinitions/
│ │ └── equipment_APIEndpointURL.json
│ └── solution.xml
└── .gitignore
The solution.xml file is the manifest. It records the solution version, the publisher prefix, and every component's type code and unique name. This file is your ground truth for what's in the solution — and it's the first place merge conflicts will surface.
<!-- solution.xml (abbreviated) -->
<ImportExportXml version="9.2.0.207">
<SolutionManifest>
<UniqueName>EquipmentInspections</UniqueName>
<LocalizedNames>
<LocalizedName description="Equipment Inspections" languagecode="1033" />
</LocalizedNames>
<Version>1.4.0.0</Version>
<Managed>0</Managed>
<Publisher>
<UniqueName>equipmentpublisher</UniqueName>
<EMailAddress>devteam@contoso.com</EMailAddress>
</Publisher>
<RootComponents>
<RootComponent type="1" id="{a3d...}" behavior="0" /> <!-- Canvas App -->
<RootComponent type="29" id="{c8f...}" behavior="0" /> <!-- Workflow -->
</RootComponents>
</SolutionManifest>
</ImportExportXml>
Here's the hard truth that most tutorials skip: the .msapp file inside CanvasApps/ is a binary zip file. It contains compiled Power Apps YAML, assets, and internal state. When two developers both modify the same Canvas App and you try to merge, Git will tell you it can't merge binary files, and you'll have to pick a winner.
This is not a solved problem. The pac canvas unpack command (separate from pac solution unpack) can go one level deeper and unpack the .msapp into YAML source files, which are diffable. Here's the command:
pac canvas unpack \
--msapp src/CanvasApps/equipment_inspectionapp_ab123/equipment_inspectionapp_ab123.msapp \
--sources src/CanvasApps/equipment_inspectionapp_ab123/CanvasAppSrc
This produces a structure like:
CanvasAppSrc/
├── src/
│ ├── App.fx.yaml
│ ├── Screen1.fx.yaml
│ └── Components/
│ └── InspectionFormComponent.fx.yaml
├── pkgs/
│ └── Microsoft.PowerFx.Core.dll ← still binary, but rarely changes
├── DataSources/
│ └── Inspections.json
└── Entropy/
└── Checksums.json ← noise, add to .gitignore
The Entropy/ folder and Checksums.json are regenerated on every unpack and create noisy diffs. Add them to .gitignore. The src/*.fx.yaml files, though — those are gold. Now you can actually do a pull request review and see that someone changed a Navigate() call or modified a gallery's Items property.
Architecture decision: Decide upfront whether you'll unpack canvas apps to YAML or treat them as blobs. Unpacking gives you full diff visibility and the ability to do code review on formula changes, but it adds complexity to your pack/unpack workflow. For apps with multiple contributors or complex business logic, it's worth it. For simple display apps maintained by one person, binary-plus-good-commit-messages may be acceptable.
This is where most Power Platform projects fail at the architecture level. Teams put everything — shared tables, reusable components, app-specific customizations, and environment configuration — into a single solution. When it's time to update the shared table schema that four different apps use, they have no clean way to do it without touching all four apps.
The layered solution pattern solves this. The core principle: separate the rate of change. Infrastructure changes rarely. App logic changes often. Configuration changes per-environment. Give each layer its own solution.
Here's the architecture we'll build around:
Layer 1: Core Platform (changes rarely — months)
This solution is managed and locked in upper environments. It owns:
equipment_Technician, equipment_Location, equipment_Equipment)Solution name: EquipmentCore
Publisher prefix: eqcore
Layer 2: App-Specific Logic (changes frequently — weeks)
This solution depends on Layer 1 but owns its own components:
Solution name: EquipmentInspections
Publisher prefix: eqinsp
Layer 3: Environment Configuration (changes per-environment)
This solution holds only environment variables and connection reference values — the things that differ between Dev, Test, UAT, and Production. It has no components of its own, just overrides.
Solution name: EquipmentInspections_Config
Publisher prefix: eqconfig
Why managed in upper environments? When you import a managed solution, its components become read-only in that environment. This is a feature, not a limitation. It prevents the "someone edited production directly" problem. The only way to change a managed component is to update the source solution and redeploy. This enforces your pipeline as the single path to production.
When EquipmentInspections (Layer 2) references the equipment_Inspection table owned by EquipmentCore (Layer 1), that reference creates a solution dependency. In solution.xml, this appears as:
<Dependencies>
<Dependency>
<Required component.type="1" solution.uniquename="EquipmentCore"
component.id="{table-guid}" />
</Dependency>
</Dependencies>
This dependency declaration means: "Do not let anyone import EquipmentInspections into an environment where EquipmentCore hasn't already been installed." Power Platform enforces this at import time. It's a contract.
The practical implication for your pipeline: Layer 1 must always be deployed before Layer 2, and Layer 2 before Layer 3. Your GitHub Actions workflow needs to encode this order explicitly.
Before writing any pipelines, get your tooling working locally. Install the Power Platform CLI:
# On macOS/Linux via npm
npm install -g @microsoft/powerplatform-actions
# Or directly via dotnet tool
dotnet tool install --global Microsoft.PowerApps.CLI.Tool
Create an Application User in your tenant for non-interactive pipeline authentication. In the Azure portal, register an app, generate a client secret, and note the:
Then add this app as an Application User in each Power Platform environment via the Power Platform Admin Center (Settings → Users → Application Users → New app user). Give it the System Administrator security role.
Authenticate the CLI:
pac auth create \
--environment "https://contoso-dev.crm.dynamics.com" \
--name "ContosoDev" \
--applicationId "your-app-id" \
--clientSecret "your-secret" \
--tenant "your-tenant-id"
Test it:
pac solution list --environment "https://contoso-dev.crm.dynamics.com"
If you see your solutions listed, you're ready to build pipelines.
Here's the full export-unpack-commit workflow. We'll automate this later, but understanding the manual steps first prevents confusion about what the automation is doing.
Step 1: Export the solution from Dev
pac solution export \
--path ./exports/EquipmentInspections.zip \
--name EquipmentInspections \
--environment "https://contoso-dev.crm.dynamics.com" \
--managed false
Always export as unmanaged from Dev. The managed version gets built during deployment.
Step 2: Unpack the solution
pac solution unpack \
--zipfile ./exports/EquipmentInspections.zip \
--folder ./solutions/EquipmentInspections/src \
--processCanvasApps true \
--allowDelete true
The --processCanvasApps true flag tells the tool to also run pac canvas unpack on any .msapp files it finds. The --allowDelete true flag means that if a component was removed from the solution, its corresponding file gets deleted from the unpacked folder — keeping your source tree honest.
Step 3: Inspect the diff and commit
cd solutions/EquipmentInspections
git diff --stat
You should see changed XML and YAML files corresponding to whatever changed in your app. Review them. Write a meaningful commit message:
git add .
git commit -m "feat(inspections): add photo capture to inspection form
- Added InspectionPhoto field to equipment_Inspection table
- Updated InspectionFormComponent to include camera control
- Added cloud flow to compress and store photo to SharePoint
- Bumped solution version to 1.5.0.0"
Step 4: Repack the solution
pac solution pack \
--zipfile ./exports/EquipmentInspections_packed.zip \
--folder ./solutions/EquipmentInspections/src \
--processCanvasApps true
Step 5: Import to the target environment
pac solution import \
--path ./exports/EquipmentInspections_packed.zip \
--environment "https://contoso-test.crm.dynamics.com" \
--activate-plugins true \
--force-overwrite true
For upper environments (UAT, Production), use the managed flag instead:
pac solution import \
--path ./exports/EquipmentInspections_managed.zip \
--environment "https://contoso-prod.crm.dynamics.com" \
--managed true \
--activate-plugins true
Warning: The
--force-overwriteflag will overwrite existing customizations. In production, you almost never want this for unmanaged solutions. For managed solutions, the import process is more predictable, but test your upgrade behavior in UAT before assuming production will behave the same way.
Now we automate. Here's the mental model for the pipeline architecture:
main branch represents what's in Productionrelease/* branches represent what's in UATdevelop branch represents what's in Testfeature/*) are for development work that gets exported from Dev, unpacked, and committedThe pipeline has three workflows:
export-from-dev.yml — Triggered manually or on a schedule; exports and unpacks the solution from Dev, opens a PRdeploy-to-test.yml — Triggered on merge to develop; deploys to Test environmentpromote-to-prod.yml — Triggered on merge to main after UAT approval; deploys managed solution to ProductionLet's build these out.
# .github/workflows/export-from-dev.yml
name: Export Solution from Dev
on:
workflow_dispatch:
inputs:
solution_name:
description: 'Solution to export'
required: true
default: 'EquipmentInspections'
solution_version:
description: 'New version number (e.g. 1.5.0.0)'
required: true
env:
DEV_ENVIRONMENT_URL: ${{ secrets.DEV_ENVIRONMENT_URL }}
CLIENT_ID: ${{ secrets.POWER_PLATFORM_APP_ID }}
CLIENT_SECRET: ${{ secrets.POWER_PLATFORM_CLIENT_SECRET }}
TENANT_ID: ${{ secrets.POWER_PLATFORM_TENANT_ID }}
jobs:
export-and-unpack:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Power Platform CLI
uses: microsoft/powerplatform-actions/actions-install@v1
- name: Bump solution version in Dev
uses: microsoft/powerplatform-actions/set-solution-version@v1
with:
environment-url: ${{ env.DEV_ENVIRONMENT_URL }}
app-id: ${{ env.CLIENT_ID }}
client-secret: ${{ env.CLIENT_SECRET }}
tenant-id: ${{ env.TENANT_ID }}
solution-name: ${{ github.event.inputs.solution_name }}
solution-version: ${{ github.event.inputs.solution_version }}
- name: Export unmanaged solution from Dev
uses: microsoft/powerplatform-actions/export-solution@v1
with:
environment-url: ${{ env.DEV_ENVIRONMENT_URL }}
app-id: ${{ env.CLIENT_ID }}
client-secret: ${{ env.CLIENT_SECRET }}
tenant-id: ${{ env.TENANT_ID }}
solution-name: ${{ github.event.inputs.solution_name }}
solution-output-file: exported/${{ github.event.inputs.solution_name }}_unmanaged.zip
managed: false
- name: Unpack solution to source
uses: microsoft/powerplatform-actions/unpack-solution@v1
with:
solution-file: exported/${{ github.event.inputs.solution_name }}_unmanaged.zip
solution-folder: solutions/${{ github.event.inputs.solution_name }}/src
solution-type: Unmanaged
process-canvas-apps: true
overwrite-files: true
- name: Create Pull Request with changes
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "export: ${{ github.event.inputs.solution_name }} v${{ github.event.inputs.solution_version }}"
branch: export/${{ github.event.inputs.solution_name }}-v${{ github.event.inputs.solution_version }}
base: develop
title: "Export: ${{ github.event.inputs.solution_name }} v${{ github.event.inputs.solution_version }}"
body: |
Automated export from Dev environment.
Solution: ${{ github.event.inputs.solution_name }}
Version: ${{ github.event.inputs.solution_version }}
**Review the diff carefully before merging.**
Check for unintended changes to shared components.
# .github/workflows/deploy-to-test.yml
name: Deploy to Test
on:
push:
branches:
- develop
paths:
- 'solutions/**'
env:
TEST_ENVIRONMENT_URL: ${{ secrets.TEST_ENVIRONMENT_URL }}
CLIENT_ID: ${{ secrets.POWER_PLATFORM_APP_ID }}
CLIENT_SECRET: ${{ secrets.POWER_PLATFORM_CLIENT_SECRET }}
TENANT_ID: ${{ secrets.POWER_PLATFORM_TENANT_ID }}
jobs:
determine-changed-solutions:
runs-on: ubuntu-latest
outputs:
changed_solutions: ${{ steps.detect.outputs.solutions }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect changed solutions
id: detect
run: |
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'solutions/' \
| cut -d'/' -f2 | sort -u | jq -R -s -c 'split("\n") | map(select(length > 0))')
echo "solutions=$CHANGED" >> $GITHUB_OUTPUT
deploy-core:
needs: determine-changed-solutions
if: contains(fromJSON(needs.determine-changed-solutions.outputs.changed_solutions), 'EquipmentCore')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: microsoft/powerplatform-actions/actions-install@v1
- name: Pack EquipmentCore solution
uses: microsoft/powerplatform-actions/pack-solution@v1
with:
solution-folder: solutions/EquipmentCore/src
solution-file: packed/EquipmentCore_unmanaged.zip
solution-type: Unmanaged
process-canvas-apps: true
- name: Import EquipmentCore to Test
uses: microsoft/powerplatform-actions/import-solution@v1
with:
environment-url: ${{ env.TEST_ENVIRONMENT_URL }}
app-id: ${{ env.CLIENT_ID }}
client-secret: ${{ env.CLIENT_SECRET }}
tenant-id: ${{ env.TENANT_ID }}
solution-file: packed/EquipmentCore_unmanaged.zip
force-overwrite: true
publish-changes: true
deploy-app:
needs: [determine-changed-solutions, deploy-core]
if: |
always() &&
(needs.deploy-core.result == 'success' || needs.deploy-core.result == 'skipped') &&
contains(fromJSON(needs.determine-changed-solutions.outputs.changed_solutions), 'EquipmentInspections')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: microsoft/powerplatform-actions/actions-install@v1
- name: Pack EquipmentInspections solution
uses: microsoft/powerplatform-actions/pack-solution@v1
with:
solution-folder: solutions/EquipmentInspections/src
solution-file: packed/EquipmentInspections_unmanaged.zip
solution-type: Unmanaged
process-canvas-apps: true
- name: Import EquipmentInspections to Test
uses: microsoft/powerplatform-actions/import-solution@v1
with:
environment-url: ${{ env.TEST_ENVIRONMENT_URL }}
app-id: ${{ env.CLIENT_ID }}
client-secret: ${{ env.CLIENT_SECRET }}
tenant-id: ${{ env.TENANT_ID }}
solution-file: packed/EquipmentInspections_unmanaged.zip
force-overwrite: true
publish-changes: true
# .github/workflows/promote-to-prod.yml
name: Promote to Production
on:
push:
branches:
- main
paths:
- 'solutions/**'
jobs:
deploy-managed-to-prod:
runs-on: ubuntu-latest
environment: production # GitHub environment with required reviewers configured
steps:
- uses: actions/checkout@v4
- uses: microsoft/powerplatform-actions/actions-install@v1
# Deploy Core first, as managed
- name: Pack EquipmentCore as Managed
uses: microsoft/powerplatform-actions/pack-solution@v1
with:
solution-folder: solutions/EquipmentCore/src
solution-file: packed/EquipmentCore_managed.zip
solution-type: Both # generates both managed and unmanaged
process-canvas-apps: true
- name: Import EquipmentCore to Production (managed)
uses: microsoft/powerplatform-actions/import-solution@v1
with:
environment-url: ${{ secrets.PROD_ENVIRONMENT_URL }}
app-id: ${{ secrets.POWER_PLATFORM_APP_ID }}
client-secret: ${{ secrets.POWER_PLATFORM_CLIENT_SECRET }}
tenant-id: ${{ secrets.POWER_PLATFORM_TENANT_ID }}
solution-file: packed/EquipmentCore_managed.zip
force-overwrite: false # Never force-overwrite prod managed solutions
publish-changes: true
convert-to-managed: true
- name: Pack EquipmentInspections as Managed
uses: microsoft/powerplatform-actions/pack-solution@v1
with:
solution-folder: solutions/EquipmentInspections/src
solution-file: packed/EquipmentInspections_managed.zip
solution-type: Both
process-canvas-apps: true
- name: Import EquipmentInspections to Production (managed)
uses: microsoft/powerplatform-actions/import-solution@v1
with:
environment-url: ${{ secrets.PROD_ENVIRONMENT_URL }}
app-id: ${{ secrets.POWER_PLATFORM_APP_ID }}
client-secret: ${{ secrets.POWER_PLATFORM_CLIENT_SECRET }}
tenant-id: ${{ secrets.POWER_PLATFORM_TENANT_ID }}
solution-file: packed/EquipmentInspections_managed.zip
force-overwrite: false
publish-changes: true
convert-to-managed: true
The environment: production key on the job is critical. Configure your GitHub production environment to require approval from named reviewers. This gives you a human gate before anything reaches production — the digital equivalent of a change advisory board, but one that doesn't require a Tuesday afternoon meeting.
The pipeline above is the happy path. Real enterprise deployments run into four consistently painful scenarios. Here's how to handle each one.
Connection references are how Canvas Apps and cloud flows connect to external services without hardcoding credentials. They're environment-specific — the SharePoint connection in Dev points to a dev tenant, while production points to the real SharePoint. When you import a solution that contains a connection reference, Power Platform will block the import if the reference isn't satisfied.
The fix is the environment-specific configuration solution (your Layer 3). After deploying Layer 2 to a new environment, deploy Layer 3 with a deploymentSettings.json file:
{
"EnvironmentVariables": [
{
"SchemaName": "equipment_APIEndpointURL",
"Value": "https://api.contoso-test.com/v2"
}
],
"ConnectionReferences": [
{
"LogicalName": "equipment_SharedOffice365Connection",
"ConnectionId": "abc123def456",
"ConnectorId": "/providers/Microsoft.PowerApps/apis/shared_office365"
}
]
}
You get the ConnectionId by querying the Power Platform API or from the connection URL in the environment's connection list. Store one of these files per environment in your repo under config/test/deploymentSettings.json, config/uat/deploymentSettings.json, etc. Feed it to the import step:
- name: Import solution with connection references
uses: microsoft/powerplatform-actions/import-solution@v1
with:
solution-file: packed/EquipmentInspections_unmanaged.zip
deployment-settings-file: config/test/deploymentSettings.json
# ... other params
Security note:
deploymentSettings.jsonfiles reference connection IDs but not connection secrets. The actual credentials are stored in the Power Platform environment's connections store. However,ConnectionIdvalues are still sensitive — they could be used to enumerate connections. Store your config files in private repos and never commit them to public repositories.
When a solution is imported, the Canvas App's "owner" defaults to the service principal doing the import. Users who had the app shared with them will still see it. But the app won't appear in the maker's "My Apps" list — which can cause support confusion.
Worse: if the original owner leaves the organization, the app can become orphaned. Establish a policy that production Canvas Apps are owned by a service account or a shared mailbox, not a named individual. Set ownership post-import:
pac canvas set-app-owner \
--environment "https://contoso-prod.crm.dynamics.com" \
--app-name "Equipment Inspection App" \
--app-owner "svc-powerplatform@contoso.com"
This distinction bites people constantly. When you import a newer version of a managed solution:
You control this via the solution-type behavior and the import parameters. By default, Power Platform imports managed solutions as updates (safer). To trigger an upgrade (and apply deletions), add the --stage-and-upgrade flag:
pac solution import \
--path EquipmentInspections_managed.zip \
--environment "https://contoso-prod.crm.dynamics.com" \
--stage-and-upgrade true
Warning: Never run
--stage-and-upgradewithout having confirmed data migration. If you deleted a column that contains production data, a staged upgrade will attempt to delete that column and fail — or worse, succeed and destroy the data. Always test schema deletions in UAT with a production data clone first.
When two solutions both contain a customization for the same component (say, both EquipmentCore and EquipmentInspections try to modify the equipment_Inspection table's form), Power Platform resolves conflicts using the solution layer order — essentially, last-imported wins for unmanaged, and for managed solutions, the layer with the higher solution import order takes precedence.
You can inspect solution layers for any component using:
pac solution get-layers \
--environment "https://contoso-prod.crm.dynamics.com" \
--component-name "equipment_Inspection"
The correct fix for layer conflicts is to make the ownership clear in your architecture: a component should be customized by exactly one solution. If EquipmentCore owns the table, it defines the columns and forms. EquipmentInspections should not add customizations to that table's forms — it should use the table through its API, not modify its shape. If you need app-specific form modifications, create a separate form within EquipmentInspections rather than modifying the shared form.
A three-tier pipeline (Dev → Test → Production) is the minimum viable ALM setup. For enterprise deployments, you need to think more carefully about the purpose of each environment and what guarantees it provides.
Here's a more complete environment model:
| Environment | Type | Solution State | Purpose | Who Has Access |
|---|---|---|---|---|
| Personal Dev | Sandbox | Unmanaged | Individual experimentation | Developer only |
| Team Dev | Sandbox | Unmanaged | Integration of features | Dev team |
| Test | Sandbox | Unmanaged | Automated functional testing | QA, DevOps |
| UAT | Sandbox | Managed | Business acceptance testing | Business users, PM |
| Production | Production | Managed | Live operations | End users |
The column that most teams miss is Solution State. UAT and Production must run managed solutions. If UAT is running unmanaged solutions, your UAT test results don't validate what production will actually behave like, because managed solutions behave differently — they lock customizations, enforce dependency chains, and resolve layer conflicts differently.
Map your Git branches to environments explicitly and enforce it:
feature/* → exports from Personal Dev, PR to develop
develop → auto-deploys to Team Dev
release/* → auto-deploys to UAT (as managed)
main → deploys to Production (as managed, with approval gate)
Use GitHub branch protection rules to enforce:
develop requires passing checks before mergerelease/* requires at least one approvermain requires two approvers and passing checksUse semantic versioning adapted for Power Platform's four-part version number: Major.Minor.Patch.Build
In your GitHub Actions workflow, set the build number automatically:
- name: Set solution version
run: |
# Extract base version from solution.xml
BASE_VERSION=$(grep -oP '(?<=<Version>)[^<]+' solutions/EquipmentInspections/src/solution.xml | head -1)
MAJOR_MINOR_PATCH=$(echo $BASE_VERSION | cut -d'.' -f1-3)
NEW_VERSION="${MAJOR_MINOR_PATCH}.${{ github.run_number }}"
echo "SOLUTION_VERSION=$NEW_VERSION" >> $GITHUB_ENV
# Update solution.xml
sed -i "s|<Version>.*</Version>|<Version>${NEW_VERSION}</Version>|" \
solutions/EquipmentInspections/src/solution.xml
This means your production solution always has a build number that directly maps to a GitHub Actions run, which in turn maps to an exact Git commit. When something breaks in production, you run pac solution get-component-version and immediately know which commit introduced the change.
In this exercise, you'll set up the full source control pipeline for a Canvas App from scratch.
Scenario: You have a Canvas App called ProjectTracker in a Dev environment. It tracks project milestones against a Dataverse table. You need to get it under source control and build a pipeline to deploy it to a Test environment.
Step 1: Create your repository structure
Create a new GitHub repository called contoso-power-platform. Create the following directory structure:
contoso-power-platform/
├── solutions/
│ └── .gitkeep
├── exports/
│ └── .gitignore # git-ignore the zip files themselves
├── config/
│ ├── test/
│ │ └── deploymentSettings.json
│ └── prod/
│ └── deploymentSettings.json
└── .github/
└── workflows/
├── export-from-dev.yml
└── deploy-to-test.yml
Add this to exports/.gitignore:
*.zip
Step 2: Export and commit your first solution
Create a solution in your Dev environment called ProjectTracker. Add your Canvas App to it. Then run:
# Authenticate
pac auth create \
--name DevEnvironment \
--environment "https://yourorg-dev.crm.dynamics.com" \
--applicationId "your-app-id" \
--clientSecret "your-secret" \
--tenant "your-tenant-id"
# Export
pac solution export \
--path exports/ProjectTracker.zip \
--name ProjectTracker \
--managed false
# Unpack
pac solution unpack \
--zipfile exports/ProjectTracker.zip \
--folder solutions/ProjectTracker/src \
--processCanvasApps true \
--allowDelete true
# Commit
git add solutions/ProjectTracker/
git commit -m "initial: add ProjectTracker solution v1.0.0.0"
git push origin develop
Step 3: Add GitHub Secrets
In your GitHub repository, go to Settings → Secrets and variables → Actions. Add:
DEV_ENVIRONMENT_URLTEST_ENVIRONMENT_URLPOWER_PLATFORM_APP_IDPOWER_PLATFORM_CLIENT_SECRETPOWER_PLATFORM_TENANT_IDStep 4: Create the deploy-to-test workflow
Using the workflow template from earlier in this lesson, create .github/workflows/deploy-to-test.yml adapted for ProjectTracker. Commit it to develop.
Step 5: Make a change and trace it through the pipeline
In your Dev environment, open the Canvas App and add a new screen with a simple label. Export the solution again with a bumped version number (1.1.0.0). Unpack, review the diff (you should see changes in the YAML source files under CanvasAppSrc/src/), commit, and push.
Watch the GitHub Actions workflow trigger. Verify the deployment appears in your Test environment.
Stretch goal: Configure a GitHub environment called test with yourself as a required reviewer. Update the workflow to use environment: test. Trigger a deployment and approve it through the GitHub Actions UI.
This means your solution references a component that isn't in the target environment. The two most common causes:
EquipmentCore deploys before EquipmentInspections.To diagnose, run:
pac solution check --path exports/EquipmentInspections.zip
This runs the Solution Checker and surfaces dependency issues before import.
The .msapp was edited by Power Apps Studio in an incompatible way, or the file is corrupted. First try re-exporting the solution fresh. If that doesn't work, check the Power Platform CLI version — canvas unpack format changes between CLI versions. Pin your GitHub Actions workflow to a specific CLI version:
- uses: microsoft/powerplatform-actions/actions-install@v1
with:
pac-version: '1.27.6'
This usually means the Entropy/Checksums.json file wasn't gitignored, or the canvas app YAML is being regenerated with different internal ordering. Add to your .gitignore:
solutions/*/src/CanvasApps/*/CanvasAppSrc/Entropy/
solutions/*/src/CanvasApps/*/*.msapp
Wait — why gitignore the .msapp itself? Because you're storing the unpacked YAML source. The .msapp is a derived artifact. You rebuild it from YAML during pack. Storing both creates duplication and confusing diffs.
Your solution.xml in source has a stale version number. The export-from-dev workflow should always bump the version before exporting, not after. If you bump in source without exporting, the packed solution will have the new version number in solution.xml but the old component versions inside it — Power Platform may accept the import but behave unpredictably.
The deploymentSettings.json wasn't applied, or the connection ID has changed. Connection IDs in Power Platform environments are not stable across environment resets or tenant migrations. After any environment recreation, re-query the connection IDs and update your config files:
pac connection list --environment "https://yourorg-test.crm.dynamics.com"
Match the logical connector name to the connector type and update deploymentSettings.json accordingly.
Someone edited a managed component directly in the target environment (usually production). This is the exact problem you set up managed solutions to prevent, but someone found a way around it — perhaps by installing an unmanaged layer on top.
To find the offending customization:
pac solution get-layers \
--environment "https://yourorg-prod.crm.dynamics.com" \
--component-name "your_component_name"
You'll see an unmanaged "Active" layer on top of your managed layer. To clear it, remove the customization from the Active solution in the target environment. This requires direct environment access and should go through a change management process.
You've now seen the full picture of enterprise ALM for Canvas Apps. Let's anchor the key principles before you go:
Source control is non-negotiable. The Power Platform CLI's unpack capability gives you human-readable, diffable source files. Canvas app YAML unpacking gives you code review capability for formula changes. Use both.
Solution layering is architectural design. Separate your infrastructure (Core), your application logic (App), and your environment configuration (Config) into distinct solutions with explicit dependency declarations. This is what makes it possible to update a shared table without touching every downstream app.
Managed solutions in upper environments enforce your process. This is the technical enforcement of your change management policy. Without it, you're relying on culture and goodwill. With it, you're relying on the platform.
Environment variables and connection references are first-class citizens. Handle them explicitly in your pipeline with deploymentSettings.json files per environment. Don't let them be an afterthought.
The pipeline ordering encodes your architecture. Deploy Layer 1 before Layer 2 before Layer 3. Make this explicit and unambiguous in your GitHub Actions workflows, using job dependencies and conditional execution.
deploy-to-test workflow actually tests the app, not just deploys it.The investment you make in ALM infrastructure now pays compound interest. Every hour you spend setting up this pipeline is ten hours you don't spend debugging a mystery production break at midnight.
Learning Path: Canvas Apps 101