Moving Power Automate flows between environments by hand (export a zip, import a zip, fix connections, repeat) does not scale and leaves no history. In this walkthrough I build a small but complete CI/CD pipeline with GitHub Actions and the official Microsoft Power Platform Actions that:
- Exports a solution from a Dev environment (unmanaged + managed)
- Unpacks it into readable source files and commits them to GitHub
- Packs it as managed and imports it into a Target environment
- Sets environment variable values per environment using a deployment settings file
Along the way I hit three real errors. I've documented each one with the cause and the fix, because that is where most of the time goes.
What you'll build
| Component | Purpose |
|---|---|
Solution CICDSampleSolution |
Container for everything that moves between environments |
| Cloud flow CICD Sample - Send Notification | Manual trigger → Compose → Office 365 Outlook Send an email (V2) |
Environment variable
cicddemo_NotificationRecipient |
The email To address, different per environment |
| Connection reference (Office 365 Outlook) | Lets the flow's connection be swapped per environment |
| Entra ID app registration | Service principal the pipeline signs in as |
| GitHub workflow 1 - Export | Dev → Git (unpacked source) |
| GitHub workflow 2 - Deploy | Git → Target (managed import) |
Dev environment ──(1 Export: export + unpack + commit)──► GitHub repo (main)
│
▼
Target environment ◄──(2 Deploy: pack managed + import + settings file)──┘
Prerequisites
| Requirement | Notes |
|---|---|
| Two Power Platform environments with Dataverse | Source (Dev) and Target. A Developer environment works fine as a target. |
| Permission to create an Entra ID app registration | To create the service principal |
| System Administrator security role in both environments | You need it yourself to grant it to the app user (see Error 2) |
| A GitHub account | Private repository is fine |
Step 1: Create a publisher and solution
In make.powerapps.com → Solutions → New solution,
create a publisher with your own prefix rather than using the default
publisher. The prefix becomes part of every component name (for example
cicddemo_NotificationRecipient), so pick it once and keep
it.
Then create the solution itself:
| Field | Value |
|---|---|
| Display name | CICD Sample Solution |
| Name | CICDSampleSolution (this is what the pipeline
uses) |
| Publisher | the publisher you just created |
Step 2: Add an environment variable
Inside the solution: New → More → Environment variable.
| Field | Value |
|---|---|
| Display name | Notification Recipient |
| Name | cicddemo_NotificationRecipient |
| Data type | Text |
| Default value | an email address for the Dev environment |
Step 3: Build a simple cloud flow inside the solution
New → Automation → Cloud flow → Instant, trigger Manually trigger a flow.
Add three steps:
- Compose with a static message.
- Send an email (V2) (Office 365 Outlook).
- Set To to the Notification Recipient environment variable (Dynamic content → Environment Variables), Body to the Compose Outputs.
Tip: if the solution does not show a connection reference after you save the flow, select the flow → Advanced → Add required objects. That pulls the connection reference into the solution. Without it, the target environment has nothing to bind a connection to.
Step 4: Create the service principal
- Entra admin center → App registrations → New registration. Single tenant, no redirect URI.
- Certificates & secrets → New client secret. Copy the value immediately; it's shown only once.
- In Power Platform admin center, for each environment: Settings → Users + permissions → Application users → New app user → pick the app → root business unit → System Administrator.
Step 5: Create the GitHub repository, secrets and variables
Create a private repository and add:
| Type | Name | Value |
|---|---|---|
| Secret | PP_CLIENT_ID |
Application (client) ID |
| Secret | PP_TENANT_ID |
Directory (tenant) ID |
| Secret | PP_CLIENT_SECRET |
Client secret value |
| Variable | SOURCE_ENVIRONMENT_URL |
https://<dev-org>.crm.dynamics.com |
| Variable | TARGET_ENVIRONMENT_URL |
https://<target-org>.crm.dynamics.com |
Environment URLs aren't secret, so repository variables keep them readable. Only the three identity values go into secrets.
Step 6: Workflow 1 — Export from Dev and commit to Git
.github/workflows/export-solution.yml
name: 1 - Export solution from Source (Dev)
# Exports the unmanaged + managed solution from the source environment,
# unpacks it into source-controlled files and commits them to main.
on:
workflow_dispatch:
inputs:
solution_name:
description: "Solution unique name"
required: true
default: "CICDSampleSolution"
permissions:
contents: write
env:
SOURCE_ENVIRONMENT_URL: ${{ vars.SOURCE_ENVIRONMENT_URL }}
SOLUTION_NAME: ${{ github.event.inputs.solution_name }}
SOLUTION_EXPORT_DIR: out/exported
SOLUTION_SOURCE_FOLDER: solutions
jobs:
export-from-source:
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Power Platform Tools
uses: microsoft/powerplatform-actions/actions-install@v1
- name: Who am I (verify service principal connection)
uses: microsoft/powerplatform-actions/who-am-i@v1
with:
environment-url: ${{ env.SOURCE_ENVIRONMENT_URL }}
app-id: ${{ secrets.PP_CLIENT_ID }}
client-secret: ${{ secrets.PP_CLIENT_SECRET }}
tenant-id: ${{ secrets.PP_TENANT_ID }}
- name: Publish all customizations
uses: microsoft/powerplatform-actions/publish-solution@v1
with:
environment-url: ${{ env.SOURCE_ENVIRONMENT_URL }}
app-id: ${{ secrets.PP_CLIENT_ID }}
client-secret: ${{ secrets.PP_CLIENT_SECRET }}
tenant-id: ${{ secrets.PP_TENANT_ID }}
- name: Export unmanaged solution
uses: microsoft/powerplatform-actions/export-solution@v1
with:
environment-url: ${{ env.SOURCE_ENVIRONMENT_URL }}
app-id: ${{ secrets.PP_CLIENT_ID }}
client-secret: ${{ secrets.PP_CLIENT_SECRET }}
tenant-id: ${{ secrets.PP_TENANT_ID }}
solution-name: ${{ env.SOLUTION_NAME }}
solution-output-file: ${{ env.SOLUTION_EXPORT_DIR }}/${{ env.SOLUTION_NAME }}.zip
managed: false
- name: Export managed solution
uses: microsoft/powerplatform-actions/export-solution@v1
with:
environment-url: ${{ env.SOURCE_ENVIRONMENT_URL }}
app-id: ${{ secrets.PP_CLIENT_ID }}
client-secret: ${{ secrets.PP_CLIENT_SECRET }}
tenant-id: ${{ secrets.PP_TENANT_ID }}
solution-name: ${{ env.SOLUTION_NAME }}
solution-output-file: ${{ env.SOLUTION_EXPORT_DIR }}/${{ env.SOLUTION_NAME }}_managed.zip
managed: true
- name: Unpack solution (Both)
uses: microsoft/powerplatform-actions/unpack-solution@v1
with:
solution-file: ${{ env.SOLUTION_EXPORT_DIR }}/${{ env.SOLUTION_NAME }}.zip
solution-folder: ${{ env.SOLUTION_SOURCE_FOLDER }}/${{ env.SOLUTION_NAME }}
solution-type: Both
overwrite-files: true
- name: Commit unpacked solution to main
shell: pwsh
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add "${{ env.SOLUTION_SOURCE_FOLDER }}/${{ env.SOLUTION_NAME }}"
if (git diff --cached --quiet) {
Write-Host "No solution changes to commit."
} else {
git commit -m "Export $env:SOLUTION_NAME from source environment (run $env:GITHUB_RUN_NUMBER)"
git push origin HEAD:main
}
| Step | Action | Why |
|---|---|---|
| Install Power Platform Tools | actions-install@v1 |
Installs the Power Platform CLI on the runner. Must be first. |
| Who am I | who-am-i@v1 |
Fails fast if the service principal can't reach the environment |
| Publish | publish-solution@v1 |
Ensures unpublished edits are included |
| Export ×2 | export-solution@v1 |
Unmanaged (for source) + managed (so unpack can capture both) |
| Unpack | unpack-solution@v1 with
solution-type: Both |
Turns the zip into reviewable XML/JSON files |
| Commit | git in PowerShell |
Pushes the unpacked source to main |
Step 7: Deployment settings file
The deployment settings file is how you give the target environment its own environment variable values (and optionally connections) without anyone clicking through import dialogs.
deployment-settings/CICDSampleSolution.COE.json
{
"EnvironmentVariables": [
{
"SchemaName": "cicddemo_NotificationRecipient",
"Value": "target-team@contoso.com"
}
],
"ConnectionReferences": [
{
"LogicalName": "new_sharedoffice365_xxxxx",
"ConnectionId": "",
"ConnectorId": "/providers/Microsoft.PowerApps/apis/shared_office365"
}
]
}
You can generate a starter version with
pac solution create-settings --solution-zip <zip> --settings-file <file>
and fill in the values.
Step 8: Workflow 2 — Deploy to Target as managed
.github/workflows/deploy-solution.yml
name: 2 - Deploy solution to Target (COE)
# Packs the source-controlled solution as MANAGED and imports it into the
# target environment, applying environment variable values and connection
# references from a deployment settings file.
on:
workflow_dispatch:
inputs:
solution_name:
description: "Solution unique name"
required: true
default: "CICDSampleSolution"
workflow_run:
workflows: ["1 - Export solution from Source (Dev)"]
types: [completed]
env:
TARGET_ENVIRONMENT_URL: ${{ vars.TARGET_ENVIRONMENT_URL }}
SOLUTION_NAME: ${{ github.event.inputs.solution_name || 'CICDSampleSolution' }}
SOLUTION_SOURCE_FOLDER: solutions
BUILD_DIR: out/build
jobs:
deploy-to-target:
# Run on manual trigger, or after a successful export run
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: windows-latest
environment: COE
steps:
- name: Checkout repository (latest main)
uses: actions/checkout@v4
with:
ref: main
- name: Install Power Platform Tools
uses: microsoft/powerplatform-actions/actions-install@v1
- name: Pack managed solution
uses: microsoft/powerplatform-actions/pack-solution@v1
with:
solution-folder: ${{ env.SOLUTION_SOURCE_FOLDER }}/${{ env.SOLUTION_NAME }}
solution-file: ${{ env.BUILD_DIR }}/${{ env.SOLUTION_NAME }}_managed.zip
solution-type: Managed
- name: Who am I (verify target connection)
uses: microsoft/powerplatform-actions/who-am-i@v1
with:
environment-url: ${{ env.TARGET_ENVIRONMENT_URL }}
app-id: ${{ secrets.PP_CLIENT_ID }}
client-secret: ${{ secrets.PP_CLIENT_SECRET }}
tenant-id: ${{ secrets.PP_TENANT_ID }}
- name: Import managed solution into target
uses: microsoft/powerplatform-actions/import-solution@v1
with:
environment-url: ${{ env.TARGET_ENVIRONMENT_URL }}
app-id: ${{ secrets.PP_CLIENT_ID }}
client-secret: ${{ secrets.PP_CLIENT_SECRET }}
tenant-id: ${{ secrets.PP_TENANT_ID }}
solution-file: ${{ env.BUILD_DIR }}/${{ env.SOLUTION_NAME }}_managed.zip
use-deployment-settings-file: true
deployment-settings-file: deployment-settings/${{ env.SOLUTION_NAME }}.COE.json
force-overwrite: true
publish-changes: true
run-asynchronously: true
max-async-wait-time: 60
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.SOLUTION_NAME }}-managed
path: ${{ env.BUILD_DIR }}/${{ env.SOLUTION_NAME }}_managed.zip
Key points:
| Setting | Why |
|---|---|
pack-solution with
solution-type: Managed |
Builds the managed zip from the files in Git, so Git is the source of truth |
use-deployment-settings-file: true |
Applies target-specific environment variable values on import |
workflow_run trigger |
Deploy starts automatically after a successful export (and is skipped after a failed one) |
environment: COE |
GitHub environment, so you can add a required-reviewer approval gate later |
Why not trigger Deploy on
push? The export workflow pushes with the defaultGITHUB_TOKEN, and pushes made with that token do not trigger other workflows.workflow_runis the clean way to chain them.
Running it
Export run — all steps green:
The unpacked solution is now in Git as readable files, so every change to the flow shows up as a diff:
Deploy started automatically through workflow_run:
The managed solution in the target environment:
Notice two things: the flow is Off, and the owner of every component is the service principal. That leads to the final, one-time step.
Step 9: Post-deployment (one time per target environment)
The managed import created the connection reference, but no connection is bound to it yet. Because of that, the flow arrives Off. Components inside a managed solution can't be edited, so open the environment's Default Solution instead:
- Solutions → Default Solution → Connection references, then search for the connection reference's logical name.
- Open it, choose your Office 365 Outlook connection (or + New connection) and Save.
- Back in the managed solution, select the flow and click Turn on.
This is a one-time step per target environment. After later deployments, check that the connection reference is still bound and the flow is still On. If an upgrade clears the binding, re-bind it here, or move to a connection the service principal is allowed to use and put its ID in the settings file.
Step 10: Test the deployed flow
Open the flow in the target environment and select Run → Continue → Run flow. The email arrives at the address held in the target value of the environment variable, not the Dev value. That proves the deployment settings file did its job.
Here is the full history of the Deploy workflow, including the failed run that taught me Error 3 below:
Troubleshooting: the three errors I hit
Error
1 —
does not have permission to access the path …/environments
| Symptom | who-am-i logs authenticated successfully, then
fails saying the service principal does not have permission to access
the environment |
| Cause | The app registration and secret are correct (Entra sign-in worked), but the app is not an Application user in that Dataverse environment |
| Fix | Add it as an Application user with System Administrator in every environment the pipeline touches |
Error 2
— PrivilegeDenied when creating the Application user
| Symptom | There was a problem adding the app to this environment… PrivilegeDenied |
| Cause | You can only assign a security role you hold yourself. Being a Global or Power Platform administrator does not automatically make you a Dataverse System Administrator in an environment, and the Default environment is the usual place this shows up. |
| Fix | Power Platform admin center → the environment → Membership → add yourself as System Administrator, wait a minute, then create the Application user again |
Error
3 — ConnectionAuthorizationFailed (403) during import
| Symptom | Import fails with An unexpected error occurred.
Solutions → History in the target shows error
8004801E: the caller does not have the minimum required
permission … on connection … under API 'shared_office365' |
| Cause | I put my user-owned Outlook connection ID in the deployment settings file. The import runs as the service principal, and the service principal isn't allowed to use a connection owned by someone else. Microsoft's guidance is that the connection must be owned by, or shared with, the owner of the connection reference. |
| Fix | Leave ConnectionId empty for per-user connectors like
Office 365 Outlook, and bind the connection once in the target after the
first deployment (see Step 9). Connections the service principal can
own, or shareable connections, can go straight into the file. |
Debugging tip: when the pipeline only says unexpected error, open Solutions → History in the target environment, select the failed import and expand More details. The real error is there.
Lessons learned
| Lesson | |
|---|---|
Run who-am-i first |
It turns a vague import failure into a clear permissions error in seconds |
| The service principal needs an Application user in each environment | Entra sign-in success ≠ Dataverse access |
| You must hold System Administrator yourself to grant it | Use Membership in the admin center |
| Imported flows are owned by the service principal and arrive Off | Plan a post-deploy step for connections and activation |
| Per-user connectors can't be bound by the service principal | Leave ConnectionId blank, bind once in the target |
| Check Solutions → History for the real error | The CLI often reports a generic message |
| Avoid the Default environment as a Dev source for real projects | Use a dedicated Developer or Sandbox environment |























No comments:
Post a Comment