Power Platform ALM

End-to-End Power Platform ALM with GitHub Actions: Dev, QA, and Production

Build a repeatable CI/CD process for Microsoft Power Platform solutions using GitHub, GitHub Actions, Power Platform CLI, and controlled environment deployments.

The release route

  • Dev
  • GitHub
  • QA
  • Production
  1. DevMake and test changes
  2. Pull requestexport-dev-solution.yml
  3. Build and Checkbuild-and-check.yml
  4. mainReviewed source of truth
  5. Managed buildbuild-managed-solution.yml
  6. QAdeploy-qa.yml
  7. ApprovalProduction reviewers
  8. Productiondeploy-prod.yml

One managed artifact travels the whole route. QA and Production receive the same build; only their deployment settings differ.

Power Platform solutions make it easier to package and move apps, flows, tables, and other components between environments. A reliable Application Lifecycle Management (ALM) process adds source control, validation, repeatable builds, environment-specific configuration, testing, and a controlled Production release.

This guide walks through an end-to-end implementation using three Power Platform environments—Dev, QA, and Production—and GitHub Actions. It is designed so you can adapt the setup to your own Microsoft tenant and repository.

The example solution is called EmployeeALMDemo. It is a sample solution for a proof of concept (POC); you can use your own solution by replacing the example name and configuring its dependencies. The guide focuses on the ALM setup and release process rather than explaining how to create each app, flow, or table inside the sample solution.

1.What we are building

The goal is to move a solution through a consistent lifecycle:

  1. Develop and validate changes in Dev.
  2. Export the solution from Dev and unpack it into source files.
  3. Review the source changes in a pull request (PR).
  4. Run automated checks before merging.
  5. Build a managed solution from the reviewed GitHub source.
  6. Deploy that build to QA and test it.
  7. Promote the same managed artifact to Production after the required approval.

Scroll sideways to see the whole diagram.

How the pipeline connects Dev, GitHub, QA and ProductionThe Dev environment is exported to an export branch and pull request in GitHub. Build and Check validates the pull request, which is reviewed and merged into main. Build Managed Solution produces a managed artifact, which is deployed to QA with qa.json and then, after approval and with the same build ID, to Production with prod.json. One Entra app registration signs in to all three environments.Power Platform: DevGitHub repositoryPower Platform: QA and ProductionDev environmentUnmanaged solutionEmployeeALMDemoExport branch + pull requestexport-dev-solution.ymlBuild and Checkbuild-and-check.yml (required check)main branchReviewed source of truthManaged solution artifactbuild-managed-solution.ymlQA environmentdeploy-qa.yml + qa.jsonProduction environmentdeploy-prod.yml + prod.jsonApproval requiredExportPR validationReview and mergeBuildDeploy the buildSame build IDMicrosoft Entra app registration (service principal)Signs in to Dev, QA and Production as an application user. Credentials live in GitHub secrets.
How the pieces connect: changes leave Dev through a pull request, GitHub turns reviewed source into one managed artifact, and that artifact is deployed to QA and then Production. One Entra application identity signs in to every environment.

High-level lifecycle

  1. Power Platform Dev environmentWhere changes are made
    Export both ZIPs and unpack (Both)
  2. GitHub export branch + PRUnpacked solution source
    PR validation
  3. Build and CheckSource + settings + Checker
    Review and merge
  4. main branchReviewed source of truth
    Build from source
  5. Managed solution artifactmanaged-solution, kept for 30 days
    Manual QA deployment
  6. QA environmentImport + functional testing
    Same build ID + approval
  7. Production environmentImport + smoke testing

Why use separate environments?

EnvironmentMain purpose
DevBuild and modify the unmanaged solution
QAValidate the managed solution in a test environment
ProductionRun the approved release for business users

An unmanaged solution is normally used during development. Managed solutions are used for downstream deployment. The Git repository stores the unpacked source, and a release artifact should be traceable to the reviewed source revision.

2.Why this guide stores unpacked source in Git

There are two broad strategies for keeping a Power Platform solution in GitHub.

AreaApproach 1: Export and store a solution ZIPApproach 2: Store unpacked source in Git
Git reviewZIP-level changes are difficult to reviewIndividual files can be reviewed
Pull requestsLimited visibility into component changesComponent-level source diffs
Merge conflictsDifficult to resolve inside ZIPsGit can merge many text-based files
Build processOften relies on exported package filesRebuild the package from reviewed source
TraceabilityPackage is the primary tracked itemSource changes and commits are traceable
Best fitSimple package backup or transferSource-controlled ALM and team development

This guide uses Approach 2 because the goal is a proper GitHub-based ALM pipeline with code review, validation, repeatable builds, and controlled deployments.

One important detail: unpacked solution files are not all plain text. Some components may contain binary or generated files, so Git diffs and merge support depend on the component type.

One source folder for both package types

The export workflow exports two ZIP files from Dev, EmployeeALMDemo.zip (unmanaged) and EmployeeALMDemo_managed.zip (managed), and keeps them side by side. It then unpacks them into a single source folder:

Bash
pac solution unpack \
  --zipfile "$UNMANAGED_ZIP" \
  --folder "$SOURCE_FOLDER" \
  --packagetype Both \
  --clobber
EmployeeALMDemo.zipUnmanaged export from Dev
EmployeeALMDemo_managed.zipManaged export from Dev
pac solution unpack
--packagetype Both
solutions/EmployeeALMDemo/One source folder in Git, reviewed in pull requests
pac solution pack
as Managed
managed-solutionArtifact with EmployeeALMDemo_managed.zip for QA and Production
Two exports from Dev become one source folder. The build packs that reviewed folder as the managed solution that QA and Production receive.

What does Both mean? The --packagetype Both option requests PAC CLI to unpack the solution in a format intended to support both managed and unmanaged package types. The matching *_managed.zip must sit beside the unmanaged ZIP when the command runs. The pipeline should verify that the combined source can successfully be packed into both types; exporting both ZIPs alone does not prove that every managed-specific difference has been incorporated.

The expected source structure is approximately:

Text
powerplatform-alm-demo/
├── .github/
│   └── workflows/
├── deployment-settings/
│   ├── qa.json
│   └── prod.json
├── solutions/
│   └── EmployeeALMDemo/
│       ├── Other/
│       │   └── Solution.xml
│       ├── Entities/
│       ├── Workflows/
│       └── ...
└── README.md

The exact component directories depend on your solution. Files that differ between the package types are stored next to each other, for example AppModule.xml and AppModule_managed.xml. You do not maintain separate Git source folders for managed and unmanaged solutions under this approach.

3.Workflow overview

This implementation uses separate workflows so each stage has a clear responsibility.

Workflow fileTrigger / usePurpose
.github/workflows/export-dev-solution.ymlManualExport Dev solution, unpack it, and open a PR
.github/workflows/build-and-check.ymlPull request targeting mainValidate source and deployment-settings files; pack and run Solution Checker
.github/workflows/build-managed-solution.ymlMerge/push to main or manual run, depending on configured triggersBuild a managed solution from the version-controlled source and publish an artifact
.github/workflows/deploy-qa.ymlManualDeploy a selected managed build to QA
.github/workflows/deploy-prod.ymlManual, with Production environment protectionDeploy the exact build tested in QA after approval
.github/workflows/codeql.ymlScheduled and/or push/PR, depending on configurationAnalyze supported repository code, including GitHub Actions workflows

The QA and Production deployments are manual in this implementation. This makes the release decision explicit while the pipeline is being introduced.

Responsibilities at a glance

export-dev-solution.yml
  1. Dev
  2. unmanaged + managed ZIPs
  3. unpacked source (Both)
  4. export branch
  5. PR
build-and-check.yml
  1. PR
  2. source validation
  3. settings JSON validation
  4. package validation
  5. Solution Checker
  6. PR status
build-managed-solution.yml
  1. main source
  2. pack as Managed
  3. managed-solution artifact
deploy-qa.yml
  1. selected build artifact
  2. QA import
  3. QA evidence
deploy-prod.yml
  1. exact QA-tested build
  2. Production approval
  3. Production import

4.Prerequisites

Prepare these items before configuring the workflows.

Microsoft Power Platform

  • A Microsoft Entra tenant.
  • Access to Power Platform admin center.
  • Three Dataverse environments: Dev, QA, and Production.
  • A Dataverse database in each environment.
  • The solution and its required dependencies available in Dev.
  • Permission to register an application in Microsoft Entra ID.
  • Permission to add an application user and assign an appropriate security role in each environment.
  • Appropriate Power Platform licensing and environment capacity.

GitHub

  • A GitHub account and repository.
  • GitHub Actions enabled for the repository.
  • Permission to configure repository secrets, variables, environments, and branch protection.
  • A fine-grained personal access token (PAT) for the export workflow to push a branch and create a PR, if using the PAT approach described in this guide.

Local tools for preparing the solution

  • Windows PowerShell.
  • Power Platform CLI (pac).
  • Access to the Dev environment and permission to export the sample solution.

Example names used in this guide

Replace these example values with the values for your own setup.

ItemExample
Solution unique nameEmployeeALMDemo
Publisher display nameALM Demo Publisher
Publisher prefixald
Repositorypowerplatform-alm-demo
Main branchmain
GitHub QA environmentQA
GitHub Production environmentProduction

Use your own environment URLs. For illustration, this guide uses dummy values such as https://your-dev.crm.dynamics.com; these are examples, not working tenant URLs.

5.Create or identify the Power Platform environments

Open the Power Platform admin center and create or identify the three environments.

Record the Dataverse URL for each environment:

Text
Dev:        https://your-dev.crm.dynamics.com
QA:         https://your-qa.crm.dynamics.com
Production: https://your-prod.crm.dynamics.com

Use the exact URLs shown for your environments. These URLs will be stored in GitHub Actions secrets.

Confirm that the sample solution can be exported from Dev and that the dependencies required by the solution are available in the target environments.

6.Register an application in Microsoft Entra ID

GitHub Actions needs a non-interactive identity to authenticate to Dataverse. This guide uses an application registration and client secret.

  1. Open the Microsoft Entra admin center.
  2. Go to Identity → Applications → App registrations.
  3. Select New registration.
  4. Give the application a name, for example PowerPlatform-ALM-GitHub.
  5. Select the single-tenant account option appropriate for your organization and register the application.
  6. From the Overview page, record the Application (client) ID and Directory (tenant) ID.
  7. Under Certificates & secrets, create a client secret and securely copy its Value.

The client secret value is what the workflow needs; it is not the secret’s ID. Store it securely and plan for rotation before expiry. Do not commit credentials to GitHub or paste them into workflow YAML.

For production-grade deployments, assess whether certificate-based or federated authentication is suitable for your organization.

Reference: Register an application with Microsoft Entra ID for Dataverse.

7.Add the application user to Dev, QA, and Production

An Entra application registration alone does not grant Dataverse access. Add the application as an application user separately in each environment.

For Dev, QA, and Production:

  1. Open the environment in Power Platform admin center.
  2. Open the environment’s application-user management area.
  3. Add an application user and select the app registration created in the previous section.
  4. Select the appropriate business unit.
  5. Assign a security role with the permissions required for the operations that the workflows perform.
  6. Save the application user.

Use least privilege and verify that the role supports the solution export/import and validation operations used by your pipeline. Access in Dev does not automatically grant access to QA or Production.

The workflows use Power Platform authentication and a who-am-i check to confirm that the service principal can connect to the intended environment.

8.Create the GitHub repository and source structure

Create a repository for the ALM project. For example, name it powerplatform-alm-demo.

A recommended structure is:

Text
powerplatform-alm-demo/
├── .github/
│   └── workflows/
│       ├── export-dev-solution.yml
│       ├── build-and-check.yml
│       ├── build-managed-solution.yml
│       ├── deploy-qa.yml
│       ├── deploy-prod.yml
│       └── codeql.yml
├── solutions/
│   └── EmployeeALMDemo/
├── deployment-settings/
│   ├── qa.json
│   └── prod.json
└── README.md

The solutions/EmployeeALMDemo/ directory contains the unpacked solution source, not just an exported ZIP. The workflow files live under .github/workflows/, where GitHub Actions can discover them.

You can use the sample EmployeeALMDemo solution for a POC or substitute your own solution. The ALM process does not require you to reproduce the sample’s individual apps, flows, or tables.

9.Configure GitHub repository variables and secrets

Open:

Repository → Settings → Secrets and variables → Actions

9.1 Repository variable

Create this repository variable:

VariableExample value
PP_SOLUTION_NAMEEmployeeALMDemo

Use the solution’s unique name, not necessarily the display name shown to users.

9.2 Repository secrets for Power Platform

Create the following repository secrets:

SecretValue
PP_APP_IDMicrosoft Entra application/client ID
PP_TENANT_IDMicrosoft Entra tenant ID
PP_CLIENT_SECRETClient secret value
DEV_ENVIRONMENT_URLDev Dataverse URL
QA_ENVIRONMENT_URLQA Dataverse URL
PROD_ENVIRONMENT_URLProduction Dataverse URL

The workflows reference these values with expressions such as:

YAML
${{ secrets.PP_CLIENT_SECRET }}
${{ secrets.QA_ENVIRONMENT_URL }}
${{ vars.PP_SOLUTION_NAME }}

Do not hard-code environment URLs or credentials into workflow code if they should be configurable per repository or tenant.

9.3 Configure the GitHub token for export branch and PR creation

The export workflow needs GitHub permissions to push its export branch and open a pull request. If you use a fine-grained PAT:

  1. Open GitHub fine-grained personal access tokens.
  2. Give the token a recognizable name, such as PowerPlatform-ALM-Automation.
  3. Select the repository owner that owns your ALM repository.
  4. Restrict the token to the specific repository.
  5. Grant Contents: Read and write and Pull requests: Read and write. Metadata access is normally read-only.
  6. Set an expiry consistent with your organization’s policy and record a rotation reminder.
  7. In the repository, open Settings → Secrets and variables → Actions → New repository secret.
  8. Name the secret ALM_GITHUB_TOKEN and paste the token value.

The token should be limited to the repository and permissions required by the workflow. Do not paste the PAT into YAML or commit it to source control. The PAT is used for GitHub operations; Power Platform authentication continues to use the Entra application credentials.

10.Protect the main branch

The main branch represents reviewed source intended for release. Changes should arrive through pull requests.

Open Repository → Settings → Rules → Rulesets (or the equivalent classic branch protection settings) and configure a rule targeting main.

Recommended configuration:

  • Require a pull request before merging.
  • Require the relevant validation status check to pass.
  • Use Build and Check / Validate Power Platform Solution as the required check if that is the check name produced by your workflow.
  • Require branches to be up to date before merging if it fits your team’s workflow.
  • Restrict bypasses so the intended review process is enforced.
  • Configure reviewer approval according to your team’s policy.

The status check may not appear in the branch protection picker until the workflow has run successfully at least once. For a personal POC, you can choose an approval policy suitable for a solo repository while still requiring automated validation.

Reference: GitHub protected branches.

11.Configure GitHub deployment environments

Open Repository → Settings → Environments and create these GitHub environments:

  • QA
  • Production

Use the same names in the workflow YAML.

QA environment

Create the QA environment. If desired and supported by your repository configuration, restrict deployments to main.

Production environment

Create the Production environment and configure required reviewers. Enable prevention of self-review and restrict deployment branches to main where those options are available.

The Production workflow must reference the Production environment, and the protection rule must be configured in GitHub settings. Adding environment: Production to YAML alone does not configure a required approval.

Reference: Control deployments with GitHub Actions environments.

12.Prepare deployment settings for QA and Production

Power Platform deployment settings supply environment-specific values for environment variables and connection references when importing a solution. Keep separate settings files for each target environment.

Expected files:

Text
deployment-settings/
├── qa.json
└── prod.json

12.1 Authenticate the Power Platform CLI to Dev

Open PowerShell and run the following commands. Replace the sample URL with your own Dev environment URL.

PowerShell
pac auth create --url "https://your-dev.crm.dynamics.com"

pac auth list

pac auth who
  • pac auth create creates an authentication profile.
  • pac auth list lists available profiles.
  • pac auth who displays the identity associated with the selected profile.

Use the profile associated with the correct environment before exporting the solution.

12.2 Export the unmanaged solution

Create a working folder and move into it:

PowerShell
New-Item -ItemType Directory -Path "C:\PowerPlatformALM" -Force
Set-Location "C:\PowerPlatformALM"

Export the sample solution as an unmanaged ZIP:

PowerShell
pac solution export --name EmployeeALMDemo --path .\EmployeeALMDemo.zip --managed false

Replace EmployeeALMDemo with your solution’s unique name. This ZIP is used to generate a deployment-settings template.

12.3 Generate a deployment-settings template

Create a folder for the settings files, then run:

PowerShell
New-Item -ItemType Directory -Path ".\deployment-settings" -Force

pac solution create-settings --solution-zip ".\EmployeeALMDemo.zip" --settings-file ".\deployment-settings\prod.json"

This command creates a settings template from the exported solution. You can use the generated file as the starting point for prod.json, then create qa.json from the same solution template and set the values appropriate for QA.

The generated template is not a guarantee that every value is ready for deployment. Review the file and set the target-specific values before committing it.

12.4 Update the settings for each environment

Review each JSON file and configure:

  • Environment-variable values for the target environment.
  • Connection-reference mappings to the intended target-environment connections.
  • Any other supported deployment settings required by the solution.

Keep qa.json configured for QA resources and prod.json configured for Production resources. Validate that both files are valid JSON.

When do these files need to change? Usually, update them when you add a new environment variable or connection reference to the solution, or when the target environment’s configuration changes. After changing the solution, regenerate or refresh the template as needed, then review and update the target-specific values in both files.

Only commit values that your organization’s policy permits in source control. If a setting contains a credential or other secret, use an approved secret-management approach rather than committing the secret as plain text.

Reference: Power Platform CLI solution commands.

13.Workflow 1 — Export the Dev solution

File
.github/workflows/export-dev-solution.yml
Purpose
Capture changes from Dev in source control without directly changing main.

The workflow is started manually when a set of Dev changes is ready to be captured. When it runs, it:

  1. Connects to the Dev environment using the configured service principal.
  2. Reads the current solution version and calculates the next version from the selected option: revision, build, minor, major, or custom.
  3. Updates the online solution version in Dev.
  4. Exports two ZIP files: EmployeeALMDemo.zip (unmanaged) and EmployeeALMDemo_managed.zip (managed).
  5. Removes the previous source and unpacks both ZIPs into solutions/<solution-name>/ with pac solution unpack --packagetype Both.
  6. Creates an automation branch, commits the source changes, pushes the branch, and opens a pull request against main.
  7. Uploads the original exports as troubleshooting artifacts, kept for 7 days.

This workflow uses ALM_GITHUB_TOKEN for the GitHub branch and PR operations. Power Platform authentication uses the PP_APP_ID, PP_TENANT_ID, PP_CLIENT_SECRET, and DEV_ENVIRONMENT_URL secrets.

Workflow code

.github/workflows/export-dev-solution.yml
name: Export Dev Solution

on:
  workflow_dispatch:
    inputs:
      version_type:
        description: "Choose how to update the solution version"
        required: true
        type: choice
        default: revision
        options:
          - revision
          - build
          - minor
          - major
          - custom

      custom_version:
        description: "Custom version (example: 2.3.0.0). Used only for custom."
        required: false
        type: string
        default: ""

permissions:
  contents: write
  pull-requests: write

env:
  SOLUTION_NAME: ${{ vars.PP_SOLUTION_NAME }}

jobs:
  export-dev-solution:
    name: Export Dev Solution
    runs-on: ubuntu-24.04

    steps:
      # 1. Checkout repository
      - name: Checkout repository
        uses: actions/checkout@v5
        with:
          fetch-depth: 0

      # 2. Install Power Platform tools
      - name: Install Power Platform tools
        uses: microsoft/powerplatform-actions/actions-install@v1

      # 3. Set up .NET for PAC CLI
      - name: Set up .NET
        uses: actions/setup-dotnet@v5
        with:
          dotnet-version: "10.0.x"

      # 4. Install PAC CLI
      - name: Install PAC CLI
        shell: bash
        run: |
          set -euo pipefail

          dotnet tool update --global Microsoft.PowerApps.CLI.Tool \
            || dotnet tool install --global Microsoft.PowerApps.CLI.Tool

          echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"

          "$HOME/.dotnet/tools/pac" help

      # 5. Verify Dev connection
      - name: Verify Dev connection
        uses: microsoft/powerplatform-actions/who-am-i@v1
        with:
          environment-url: ${{ secrets.DEV_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_APP_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}

      # 6. Authenticate PAC CLI to Dev
      - name: Authenticate PAC CLI to Dev
        shell: bash
        env:
          DEV_URL: ${{ secrets.DEV_ENVIRONMENT_URL }}
          APP_ID: ${{ secrets.PP_APP_ID }}
          CLIENT_SECRET: ${{ secrets.PP_CLIENT_SECRET }}
          TENANT_ID: ${{ secrets.PP_TENANT_ID }}
        run: |
          set -euo pipefail

          pac auth create \
            --name "Dev-SP" \
            --url "$DEV_URL" \
            --applicationId "$APP_ID" \
            --clientSecret "$CLIENT_SECRET" \
            --tenant "$TENANT_ID"

          pac auth select --name "Dev-SP"

      # 7. Calculate next solution version
      - name: Calculate solution version
        id: version
        shell: bash
        env:
          DEV_URL: ${{ secrets.DEV_ENVIRONMENT_URL }}
          VERSION_TYPE: ${{ inputs.version_type }}
          CUSTOM_VERSION: ${{ inputs.custom_version }}
        run: |
          set -euo pipefail

          if [ -z "$SOLUTION_NAME" ]; then
            echo "::error::PP_SOLUTION_NAME is not configured."
            exit 1
          fi

          OUTPUT=$(pac solution online-version \
            --solution-name "$SOLUTION_NAME" \
            --environment "$DEV_URL")

          echo "$OUTPUT"

          CURRENT_VERSION=$(printf '%s\n' "$OUTPUT" |
            grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' |
            head -n 1 || true)

          if [[ ! "$CURRENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
            echo "::error::Could not determine current solution version."
            exit 1
          fi

          IFS='.' read -r MAJOR MINOR BUILD REVISION <<< "$CURRENT_VERSION"

          case "$VERSION_TYPE" in
            revision)
              REVISION=$((REVISION + 1))
              NEW_VERSION="$MAJOR.$MINOR.$BUILD.$REVISION"
              ;;
            build)
              BUILD=$((BUILD + 1))
              NEW_VERSION="$MAJOR.$MINOR.$BUILD.0"
              ;;
            minor)
              MINOR=$((MINOR + 1))
              NEW_VERSION="$MAJOR.$MINOR.0.0"
              ;;
            major)
              MAJOR=$((MAJOR + 1))
              NEW_VERSION="$MAJOR.0.0.0"
              ;;
            custom)
              if [[ ! "$CUSTOM_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
                echo "::error::Enter a valid four-part version, e.g. 2.3.0.0."
                exit 1
              fi
              NEW_VERSION="$CUSTOM_VERSION"
              ;;
            *)
              echo "::error::Unsupported version type: $VERSION_TYPE"
              exit 1
              ;;
          esac

          IFS='.' read -r V1 V2 V3 V4 <<< "$NEW_VERSION"

          for PART in "$V1" "$V2" "$V3" "$V4"; do
            if (( PART > 65535 )); then
              echo "::error::Version components must not exceed 65535."
              exit 1
            fi
          done

          if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then
            echo "::error::New version must differ from current version."
            exit 1
          fi

          echo "Current version: $CURRENT_VERSION"
          echo "New version: $NEW_VERSION"

          echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
          echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"

      # 8. Update solution version in Dev
      - name: Update solution version in Dev
        shell: bash
        env:
          DEV_URL: ${{ secrets.DEV_ENVIRONMENT_URL }}
          NEW_VERSION: ${{ steps.version.outputs.new_version }}
        run: |
          set -euo pipefail

          pac solution online-version \
            --solution-name "$SOLUTION_NAME" \
            --solution-version "$NEW_VERSION" \
            --environment "$DEV_URL"

          echo "Updated $SOLUTION_NAME to version $NEW_VERSION."

      # 9. Create export directory
      - name: Create export directory
        shell: bash
        run: mkdir -p out

      # 10. Export unmanaged solution
      - name: Export unmanaged solution
        uses: microsoft/powerplatform-actions/export-solution@v1
        with:
          environment-url: ${{ secrets.DEV_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_APP_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}
          solution-name: ${{ env.SOLUTION_NAME }}
          solution-output-file: out/${{ env.SOLUTION_NAME }}.zip
          managed: false
          overwrite: true
          run-asynchronously: true
          max-async-wait-time: 60

      # 11. Export managed solution into the SAME directory.
      # PAC CLI Both unpacking uses the matching managed ZIP.
      - name: Export managed solution
        uses: microsoft/powerplatform-actions/export-solution@v1
        with:
          environment-url: ${{ secrets.DEV_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_APP_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}
          solution-name: ${{ env.SOLUTION_NAME }}
          solution-output-file: out/${{ env.SOLUTION_NAME }}_managed.zip
          managed: true
          overwrite: true
          run-asynchronously: true
          max-async-wait-time: 60

      # 12. Verify both exports and their matching names
      - name: Verify exported packages
        shell: bash
        run: |
          set -euo pipefail

          UNMANAGED_ZIP="out/${SOLUTION_NAME}.zip"
          MANAGED_ZIP="out/${SOLUTION_NAME}_managed.zip"

          if [ ! -s "$UNMANAGED_ZIP" ]; then
            echo "::error::Unmanaged ZIP missing: $UNMANAGED_ZIP"
            exit 1
          fi

          if [ ! -s "$MANAGED_ZIP" ]; then
            echo "::error::Managed ZIP missing: $MANAGED_ZIP"
            exit 1
          fi

          unzip -t "$UNMANAGED_ZIP"
          unzip -t "$MANAGED_ZIP"

          echo "Both solution exports are present and valid."
          ls -lh "$UNMANAGED_ZIP" "$MANAGED_ZIP"

      # 13. Unpack BOTH formats into the Git source folder.
      # The matching *_managed.zip must remain beside the unmanaged ZIP.
      - name: Unpack combined solution source
        shell: bash
        run: |
          set -euo pipefail

          UNMANAGED_ZIP="out/${SOLUTION_NAME}.zip"
          SOURCE_FOLDER="solutions/${SOLUTION_NAME}"

          mkdir -p solutions

          # Remove old source so files from an earlier export
          # cannot accidentally remain in the new source.
          rm -rf "$SOURCE_FOLDER"

          pac solution unpack \
            --zipfile "$UNMANAGED_ZIP" \
            --folder "$SOURCE_FOLDER" \
            --packagetype Both \
            --clobber

          if [ ! -s "$SOURCE_FOLDER/Other/Solution.xml" ]; then
            echo "::error::Unpacked Solution.xml was not found."
            exit 1
          fi

          echo "Combined solution source created successfully."
          echo "Source folder: $SOURCE_FOLDER"
          find "$SOURCE_FOLDER" -type f | wc -l

      # 14. Stage the source changes
      - name: Stage solution source
        shell: bash
        run: |
          set -euo pipefail

          git add "solutions/$SOLUTION_NAME"

          if git diff --cached --quiet; then
            echo "No solution source changes detected."
            echo "Dev version may already have been updated."
            exit 0
          fi

      # 15. Push export branch and create PR
      - name: Push export branch and create pull request
        shell: bash
        env:
          ALM_GITHUB_TOKEN: ${{ secrets.ALM_GITHUB_TOKEN }}
          GH_TOKEN: ${{ secrets.ALM_GITHUB_TOKEN }}
          NEW_VERSION: ${{ steps.version.outputs.new_version }}
          CURRENT_VERSION: ${{ steps.version.outputs.current_version }}
          VERSION_TYPE: ${{ inputs.version_type }}
        run: |
          set -euo pipefail

          if [ -z "$ALM_GITHUB_TOKEN" ]; then
            echo "::error::ALM_GITHUB_TOKEN is not configured."
            exit 1
          fi

          # Skip creating an empty PR.
          if git diff --cached --quiet; then
            echo "No staged changes. Skipping PR creation."
            exit 0
          fi

          git config user.name "github-actions[bot]"
          git config user.email \
            "41898282+github-actions[bot]@users.noreply.github.com"

          BRANCH_NAME="automation/export-dev-${GITHUB_RUN_ID}"

          git checkout -b "$BRANCH_NAME"

          git commit \
            -m "Export $SOLUTION_NAME from Dev ($NEW_VERSION)"

          git remote set-url origin \
            "https://x-access-token:${ALM_GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"

          git push --set-upstream origin "$BRANCH_NAME"

          PR_URL=$(gh pr create \
            --repo "$GITHUB_REPOSITORY" \
            --base main \
            --head "$BRANCH_NAME" \
            --title "Export $SOLUTION_NAME from Dev - v$NEW_VERSION" \
            --body "## Summary
          - Solution: $SOLUTION_NAME
          - Previous version: $CURRENT_VERSION
          - New version: $NEW_VERSION
          - Version update type: $VERSION_TYPE
          - Exported managed and unmanaged packages from Dev.
          - Unpacked both formats into the source folder for Git.

          ## Validation
          Review the source changes and ensure required checks pass before merging.
          The managed build workflow runs after the source is merged to main.")

          echo "Pull request created: $PR_URL"

      # 16. Upload exported ZIPs for troubleshooting
      - name: Upload exported packages
        if: always()
        uses: actions/upload-artifact@v6
        with:
          name: ${{ env.SOLUTION_NAME }}-dev-exports-${{ github.run_id }}
          path: out/*.zip
          if-no-files-found: ignore
          retention-days: 7

After the workflow completes, review the PR’s source diff. Confirm that the changes are the ones you intended to capture from Dev.

14.Workflow 2 — Build and Check

File
.github/workflows/build-and-check.yml
Purpose
Validate proposed changes before they are merged into main.

The workflow runs on pull requests targeting main. Depending on the final YAML, it should:

  1. Check out the proposed source.
  2. Install the required Power Platform tooling.
  3. Confirm that the solution source folder exists.
  4. Validate deployment-settings/qa.json and deployment-settings/prod.json.
  5. Pack the unmanaged solution from source.
  6. Run Power Platform Solution Checker.
  7. Publish diagnostics or other check results when available.

The validation status is used by branch protection to decide whether the PR can be merged. In the POC, the ruleset protects main and requires the check named Validate Power Platform Solution. Because the POC is maintained by one person, it uses zero required human approvals.

A successful validation workflow does not automatically guarantee that the solution will import successfully into every target environment. Deployment and environment-specific testing remain necessary.

Workflow code

.github/workflows/build-and-check.yml
name: Build and Check

on:
  pull_request:
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: read
  security-events: write

jobs:
  validate:
    name: Validate Power Platform Solution
    runs-on: ubuntu-latest

    steps:
      # 1. Checkout source code
      - name: Checkout repository
        uses: actions/checkout@v4

      # 2. Install Power Platform tools
      - name: Install Power Platform Tools
        uses: microsoft/powerplatform-actions/actions-install@v1

      # 3. Validate solution folder
      - name: Validate solution folder
        shell: bash
        run: |
          set -euo pipefail

          SOLUTION_FOLDER="solutions/EmployeeALMDemo"

          if [ ! -d "$SOLUTION_FOLDER" ]; then
            echo "::error::Solution folder not found: $SOLUTION_FOLDER"
            exit 1
          fi

          echo "Solution source folder found."

      # 4. Validate deployment settings JSON
      - name: Validate deployment settings
        shell: bash
        run: |
          set -euo pipefail

          python - <<'PY'
          import json
          from pathlib import Path

          files = [
              Path("deployment-settings/qa.json"),
              Path("deployment-settings/prod.json"),
          ]

          for file in files:
              if not file.is_file():
                  raise SystemExit(f"Missing file: {file}")

              with file.open(encoding="utf-8") as stream:
                  json.load(stream)

              print(f"Valid JSON: {file}")
          PY

      # 5. Create output directory
      - name: Prepare output directory
        shell: bash
        run: mkdir -p out

      # 6. Pack solution
      - name: Pack Power Platform Solution
        uses: microsoft/powerplatform-actions/pack-solution@v1
        with:
          solution-folder: solutions/EmployeeALMDemo
          solution-file: out/EmployeeALMDemo.zip
          solution-type: Unmanaged

      # 7. Run Power Platform Solution Checker
      - name: Run Power Platform Solution Checker
        uses: microsoft/powerplatform-actions/check-solution@v1
        with:
          environment-url: ${{ secrets.DEV_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_APP_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}
          path: out/EmployeeALMDemo.zip
          geo: India
          fail-on-analysis-error: true
          checker-logs-artifact-name: solution-checker-logs

      # 8. Upload generated files for troubleshooting
      - name: Upload Solution Checker results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: power-platform-solution-checker-results
          path: out/
          if-no-files-found: warn

A successful pack step and a successful Solution Checker run are different signals. Review the complete workflow result and its diagnostics.

15.Workflow 3 — Build the managed solution

File
.github/workflows/build-managed-solution.yml
Purpose
Produce the managed solution package from the reviewed GitHub source.

This is the key difference between source-based ALM and simply deploying the ZIP exported from Dev. The workflow runs when solution source is merged into main, and can also be started manually. It:

  1. Checks out the merged Git source.
  2. Validates the solution folder and its Solution.xml metadata.
  3. Uses PAC CLI to pack the source as a managed solution.
  4. Checks that the output ZIP exists and is valid.
  5. Uploads the resulting ZIP as a GitHub Actions artifact.

The artifact used by this POC:

Text
Artifact:  managed-solution
ZIP:       EmployeeALMDemo_managed.zip
Retention: 30 days

The artifact name is important because the QA and Production workflows download it by that name. Retention is controlled by the workflow’s artifact-upload configuration and repository policy.

Packing as Managed only works when the source was unpacked with both package types. If the source contains only unmanaged files, the managed pack fails, which is why the export workflow uses --packagetype Both.

Workflow code

.github/workflows/build-managed-solution.yml
name: Build Managed Solution

on:
  push:
    branches:
      - main
    paths:
      - "solutions/**"
      - ".github/workflows/build-managed-solution.yml"

  workflow_dispatch:

permissions:
  contents: read

env:
  SOLUTION_NAME: ${{ vars.PP_SOLUTION_NAME }}

jobs:
  build-managed-solution:
    name: Build Managed Solution
    runs-on: ubuntu-24.04

    steps:
      # 1. Checkout the merged solution source
      - name: Checkout repository
        uses: actions/checkout@v5

      # 2. Validate the solution source without assuming
      #    that Solution.xml must contain Managed=0
      - name: Validate solution source
        shell: bash
        run: |
          set -euo pipefail

          if [ -z "$SOLUTION_NAME" ]; then
            echo "::error::PP_SOLUTION_NAME is not configured."
            exit 1
          fi

          SOLUTION_FOLDER="solutions/$SOLUTION_NAME"
          SOLUTION_XML="$SOLUTION_FOLDER/Other/Solution.xml"

          if [ ! -d "$SOLUTION_FOLDER" ]; then
            echo "::error::Solution folder not found: $SOLUTION_FOLDER"
            exit 1
          fi

          if [ ! -s "$SOLUTION_XML" ]; then
            echo "::error::Solution metadata not found: $SOLUTION_XML"
            exit 1
          fi

          echo "Solution folder found: $SOLUTION_FOLDER"
          echo "Solution metadata found: $SOLUTION_XML"

          echo "Managed metadata values, if present:"
          grep -n -E '<Managed>[[:space:]]*[01][[:space:]]*</Managed>' \
            "$SOLUTION_XML" || true

          echo "Source file count:"
          find "$SOLUTION_FOLDER" -type f | wc -l

          echo "Solution source validation passed."

      # 3. Install Power Platform tools
      - name: Install Power Platform tools
        uses: microsoft/powerplatform-actions/actions-install@v1

      # 4. Create artifact directory
      - name: Create artifact directory
        shell: bash
        run: mkdir -p artifacts

      # 5. Pack the committed source as Managed
      - name: Pack managed solution
        id: pack
        uses: microsoft/powerplatform-actions/pack-solution@v1
        with:
          solution-folder: solutions/${{ env.SOLUTION_NAME }}
          solution-file: artifacts/${{ env.SOLUTION_NAME }}_managed.zip
          solution-type: Managed

      # 6. Verify the generated ZIP
      - name: Verify managed solution package
        shell: bash
        run: |
          set -euo pipefail

          PACKAGE="artifacts/${SOLUTION_NAME}_managed.zip"

          if [ ! -s "$PACKAGE" ]; then
            echo "::error::Managed package was not created or is empty."
            exit 1
          fi

          echo "Checking ZIP integrity..."
          unzip -t "$PACKAGE"

          echo "Checking package contents..."
          unzip -Z1 "$PACKAGE" | head -n 30

          echo "Managed package created successfully."
          ls -lh "$PACKAGE"

      # 7. Upload artifact for QA and Production
      - name: Upload managed solution artifact
        uses: actions/upload-artifact@v6
        with:
          name: managed-solution
          path: artifacts/${{ env.SOLUTION_NAME }}_managed.zip
          if-no-files-found: error
          retention-days: 30

The important release principle is that the managed artifact should be built from the reviewed source in GitHub. This makes the build traceable to the commit that passed PR validation.

16.Workflow 4 — Deploy to QA

File
.github/workflows/deploy-qa.yml
Purpose
Deploy a selected managed build to QA without rebuilding it.

The workflow is started manually and accepts an optional build_run_id. If no run ID is supplied, it uses the latest successful Build Managed Solution run on main. It then:

  1. Validates the repository configuration and deployment-settings/qa.json.
  2. Confirms that the selected build has a managed-solution artifact that has not expired.
  3. Downloads the artifact from that specific build run.
  4. Checks that EmployeeALMDemo_managed.zip exists and is not empty.
  5. Confirms the connection to QA.
  6. Imports the managed solution using deployment-settings/qa.json for environment-specific connection references and environment variables.
  7. Publishes changes and waits for the asynchronous import according to the configured timeout.

The important part is that QA downloads the build artifact rather than exporting a fresh package from Dev.

Workflow code

.github/workflows/deploy-qa.yml
name: Deploy to QA

on:
  workflow_dispatch:
    inputs:
      build_run_id:
        description: 'Optional: GitHub Actions run ID that created the managed artifact'
        required: false
        type: string

permissions:
  contents: read
  actions: read

env:
  SOLUTION_NAME: ${{ vars.PP_SOLUTION_NAME }}
  ARTIFACT_NAME: managed-solution
  SOLUTION_ZIP: out/${{ vars.PP_SOLUTION_NAME }}_managed.zip

jobs:
  deploy-qa:
    name: Import Managed Solution into QA
    runs-on: ubuntu-24.04
    environment: QA

    steps:
      # 1. Checkout repository
      - name: Checkout repository
        uses: actions/checkout@v5

      # 2. Validate configuration and deployment settings
      - name: Validate configuration
        shell: bash
        env:
          QA_URL: ${{ secrets.QA_ENVIRONMENT_URL }}
          APP_ID: ${{ secrets.PP_APP_ID }}
          TENANT_ID: ${{ secrets.PP_TENANT_ID }}
          CLIENT_SECRET: ${{ secrets.PP_CLIENT_SECRET }}
        run: |
          set -euo pipefail

          if [ -z "$SOLUTION_NAME" ]; then
            echo "::error::Repository variable PP_SOLUTION_NAME is missing."
            exit 1
          fi

          if [ -z "$QA_URL" ] || [ -z "$APP_ID" ] || \
             [ -z "$TENANT_ID" ] || [ -z "$CLIENT_SECRET" ]; then
            echo "::error::One or more required GitHub secrets are missing."
            exit 1
          fi

          if [ ! -f deployment-settings/qa.json ]; then
            echo "::error::deployment-settings/qa.json not found."
            exit 1
          fi

          python -m json.tool deployment-settings/qa.json > /dev/null

          echo "Configuration validation passed."
          echo "Solution: $SOLUTION_NAME"
          echo "Expected artifact: $ARTIFACT_NAME"

      # 3. Select the successful managed solution build
      - name: Select managed solution build
        id: select-build
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
          INPUT_RUN_ID: ${{ inputs.build_run_id }}
        run: |
          set -euo pipefail

          if [ -n "$INPUT_RUN_ID" ]; then
            if [[ ! "$INPUT_RUN_ID" =~ ^[0-9]+$ ]]; then
              echo "::error::Build run ID must contain digits only."
              exit 1
            fi

            RUN_ID="$INPUT_RUN_ID"

            echo "Using manually selected build run: $RUN_ID"
          else
            RUN_ID=$(gh run list \
              --repo "$GITHUB_REPOSITORY" \
              --workflow build-managed-solution.yml \
              --branch main \
              --status success \
              --limit 1 \
              --json databaseId \
              --jq '.[0].databaseId // empty')

            if [ -z "$RUN_ID" ]; then
              echo "::error::No successful Build Managed Solution run found on main."
              echo "Run the build workflow successfully before deploying to QA."
              exit 1
            fi

            echo "Selected latest successful build run: $RUN_ID"
          fi

          echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"

      # 4. Verify that the selected run contains the expected artifact
      - name: Verify build artifact exists
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
          BUILD_RUN_ID: ${{ steps.select-build.outputs.run_id }}
        run: |
          set -euo pipefail

          echo "Checking artifact '$ARTIFACT_NAME' in run '$BUILD_RUN_ID'..."

          ARTIFACT_INFO=$(gh api \
            "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN_ID/artifacts" \
            --jq ".artifacts[] | select(.name == \"$ARTIFACT_NAME\" and .expired == false) | {name: .name, expired: .expired, size_in_bytes: .size_in_bytes}" \
            | head -n 1 || true)

          if [ -z "$ARTIFACT_INFO" ]; then
            echo "::error::Artifact '$ARTIFACT_NAME' was not found in build run $BUILD_RUN_ID."
            echo "Open that run in GitHub Actions and check its Artifacts section."
            echo "Confirm that the build completed its Upload managed solution artifact step."
            exit 1
          fi

          echo "$ARTIFACT_INFO"
          echo "Artifact exists and is not expired."

      # 5. Download the managed solution from the selected build run
      - name: Download managed solution artifact
        uses: actions/download-artifact@v8
        with:
          name: ${{ env.ARTIFACT_NAME }}
          path: out
          repository: ${{ github.repository }}
          run-id: ${{ steps.select-build.outputs.run_id }}
          github-token: ${{ github.token }}

      # 6. Verify the downloaded managed solution ZIP
      - name: Verify managed solution ZIP
        shell: bash
        run: |
          set -euo pipefail

          PACKAGE="$SOLUTION_ZIP"

          if [ ! -s "$PACKAGE" ]; then
            echo "::error::Expected managed solution ZIP not found: $PACKAGE"
            echo "Downloaded files:"
            find out -maxdepth 2 -type f -print || true
            exit 1
          fi

          echo "Checking ZIP integrity..."
          unzip -t "$PACKAGE"

          echo "Managed solution artifact verified:"
          ls -lh "$PACKAGE"

      # 7. Install Power Platform tools
      - name: Install Power Platform tools
        uses: microsoft/powerplatform-actions/actions-install@v1

      # 8. Verify connection to QA
      - name: Verify QA environment connection
        uses: microsoft/powerplatform-actions/who-am-i@v1
        with:
          environment-url: ${{ secrets.QA_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_APP_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}

      # 9. Import the managed solution into QA
      - name: Import managed solution into QA
        uses: microsoft/powerplatform-actions/import-solution@v1
        with:
          environment-url: ${{ secrets.QA_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_APP_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}
          solution-file: ${{ env.SOLUTION_ZIP }}
          use-deployment-settings-file: true
          deployment-settings-file: deployment-settings/qa.json
          publish-changes: true
          run-asynchronously: true
          max-async-wait-time: 60

After deployment, test the solution in the QA environment and record the build run ID and test outcome. QA settings should refer to QA connections and environment-specific values.

17.Workflow 5 — Deploy to Production

File
.github/workflows/deploy-prod.yml
Purpose
Deploy the exact managed build that passed QA testing.

The Production workflow follows the same pattern as QA, with an extra release-control step. It has two jobs.

Find Build to Deploy runs before anyone is asked to approve:

  1. Validates the repository variable and the Production secrets.
  2. Uses the build_run_id you enter, or the latest successful Build Managed Solution run on main if the input is left blank.
  3. Confirms that the run is a completed, successful Build Managed Solution run on main.
  4. Confirms that its managed-solution artifact is still available.
  5. Writes a build review summary (run ID, commit, and run link) for the approver.

Deploy Managed Solution to Production runs after approval:

  1. Waits for the required approval on the GitHub Production environment.
  2. Validates deployment-settings/prod.json.
  3. Downloads the managed-solution artifact from the selected run and verifies the ZIP.
  4. Imports the managed solution using deployment-settings/prod.json, publishes changes, and waits for the import.
  5. Writes a deployment summary.

Workflow code

.github/workflows/deploy-prod.yml
name: Deploy to Production

on:
  workflow_dispatch:
    inputs:
      build_run_id:
        description: 'Optional: Enter a specific successful Build Managed Solution run ID. Leave blank to automatically select the latest successful build from main.'
        required: false
        type: string

permissions:
  contents: read
  actions: read

env:
  SOLUTION_NAME: ${{ vars.PP_SOLUTION_NAME }}
  ARTIFACT_NAME: managed-solution
  SOLUTION_ZIP: out/${{ vars.PP_SOLUTION_NAME }}_managed.zip

jobs:
  resolve-build:
    name: Find Build to Deploy
    runs-on: ubuntu-latest

    outputs:
      build_run_id: ${{ steps.resolve.outputs.build_run_id }}

    steps:
      - name: Validate repository configuration
        shell: bash
        env:
          PP_APP_ID: ${{ secrets.PP_APP_ID }}
          PP_TENANT_ID: ${{ secrets.PP_TENANT_ID }}
          PP_CLIENT_SECRET: ${{ secrets.PP_CLIENT_SECRET }}
          PROD_ENVIRONMENT_URL: ${{ secrets.PROD_ENVIRONMENT_URL }}
        run: |
          set -euo pipefail

          if [[ -z "${SOLUTION_NAME:-}" ]]; then
            echo "::error::Repository variable PP_SOLUTION_NAME is not configured."
            exit 1
          fi

          if [[ -z "$PP_APP_ID" || -z "$PP_TENANT_ID" ||
                -z "$PP_CLIENT_SECRET" || -z "$PROD_ENVIRONMENT_URL" ]]; then
            echo "::error::One or more required Power Platform secrets are missing."
            exit 1
          fi

      - name: Resolve Build Run ID
        id: resolve
        env:
          GH_TOKEN: ${{ github.token }}
          REQUESTED_BUILD_RUN_ID: ${{ inputs.build_run_id }}
        shell: bash
        run: |
          set -euo pipefail

          if [[ -n "$REQUESTED_BUILD_RUN_ID" ]]; then
            if [[ ! "$REQUESTED_BUILD_RUN_ID" =~ ^[0-9]+$ ]]; then
              echo "::error::Build Run ID must contain only numbers."
              exit 1
            fi

            BUILD_RUN_ID="$REQUESTED_BUILD_RUN_ID"
            echo "Using specified Build Run ID: $BUILD_RUN_ID"
          else
            echo "Finding the latest successful Build Managed Solution run on main..."

            BUILD_RUN_ID=$(gh run list \
              --repo "$GITHUB_REPOSITORY" \
              --workflow "build-managed-solution.yml" \
              --branch main \
              --status success \
              --limit 1 \
              --json databaseId \
              --jq '.[0].databaseId // empty')

            if [[ -z "$BUILD_RUN_ID" ]]; then
              echo "::error::No successful Build Managed Solution run was found on main."
              exit 1
            fi

            echo "Automatically selected Build Run ID: $BUILD_RUN_ID"
          fi

          RUN_DETAILS=$(gh run view "$BUILD_RUN_ID" \
            --repo "$GITHUB_REPOSITORY" \
            --json databaseId,workflowName,status,conclusion,headBranch,headSha,url)

          echo "$RUN_DETAILS"

          WORKFLOW_NAME=$(echo "$RUN_DETAILS" | jq -r '.workflowName')
          RUN_STATUS=$(echo "$RUN_DETAILS" | jq -r '.status')
          RUN_CONCLUSION=$(echo "$RUN_DETAILS" | jq -r '.conclusion')
          RUN_BRANCH=$(echo "$RUN_DETAILS" | jq -r '.headBranch')
          RUN_URL=$(echo "$RUN_DETAILS" | jq -r '.url')
          RUN_SHA=$(echo "$RUN_DETAILS" | jq -r '.headSha')

          if [[ "$WORKFLOW_NAME" != "Build Managed Solution" ]]; then
            echo "::error::Selected run is not the Build Managed Solution workflow."
            exit 1
          fi

          if [[ "$RUN_STATUS" != "completed" ||
                "$RUN_CONCLUSION" != "success" ||
                "$RUN_BRANCH" != "main" ]]; then
            echo "::error::The selected build must be completed successfully on main."
            exit 1
          fi

          ARTIFACTS=$(gh api \
            "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN_ID/artifacts")

          ARTIFACT_COUNT=$(echo "$ARTIFACTS" | jq \
            --arg name "managed-solution" \
            '[.artifacts[] | select(.name == $name and .expired == false)] | length')

          if [[ "$ARTIFACT_COUNT" -lt 1 ]]; then
            echo "::error::The selected build has no available managed-solution artifact."
            exit 1
          fi

          echo "build_run_id=$BUILD_RUN_ID" >> "$GITHUB_OUTPUT"

          {
            echo "## Production Deployment - Build Review"
            echo ""
            echo "- **Solution:** $SOLUTION_NAME"
            echo "- **Build Run ID:** $BUILD_RUN_ID"
            echo "- **Source branch:** $RUN_BRANCH"
            echo "- **Commit:** \`$RUN_SHA\`"
            echo "- **Build status:** $RUN_CONCLUSION"
            echo "- **Artifact:** managed-solution"
            echo "- **Build run:** $RUN_URL"
            echo ""
            echo "Review the selected build before approving Production deployment."
          } >> "$GITHUB_STEP_SUMMARY"

  deploy-prod:
    name: Deploy Managed Solution to Production
    runs-on: windows-latest
    needs: resolve-build

    environment: Production

    concurrency:
      group: production-deployment
      cancel-in-progress: false

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Display selected build
        shell: pwsh
        env:
          BUILD_RUN_ID: ${{ needs.resolve-build.outputs.build_run_id }}
        run: |
          Write-Host "Selected Build Run ID: $env:BUILD_RUN_ID"
          Write-Host "Artifact: managed-solution"

      - name: Validate Production configuration
        shell: pwsh
        run: |
          $ErrorActionPreference = "Stop"

          if ([string]::IsNullOrWhiteSpace($env:SOLUTION_NAME)) {
            throw "Repository variable PP_SOLUTION_NAME is not configured."
          }

          $settingsFile = "deployment-settings/prod.json"

          if (!(Test-Path $settingsFile)) {
            throw "Production deployment settings not found: $settingsFile"
          }

          $settings = Get-Content $settingsFile -Raw | ConvertFrom-Json

          if ($null -eq $settings) {
            throw "Production deployment settings are empty or invalid."
          }

          Write-Host "Production deployment configuration validated."

      - name: Download selected managed solution artifact
        uses: actions/download-artifact@v8
        with:
          name: managed-solution
          path: out
          repository: ${{ github.repository }}
          run-id: ${{ needs.resolve-build.outputs.build_run_id }}
          github-token: ${{ github.token }}

      - name: Verify downloaded solution ZIP
        shell: pwsh
        run: |
          $ErrorActionPreference = "Stop"

          $zipPath = $env:SOLUTION_ZIP

          if (!(Test-Path $zipPath)) {
            throw "Expected managed solution ZIP not found: $zipPath"
          }

          $file = Get-Item $zipPath

          if ($file.Length -le 0) {
            throw "Managed solution ZIP is empty."
          }

          Add-Type -AssemblyName System.IO.Compression.FileSystem

          $archive = [System.IO.Compression.ZipFile]::OpenRead(
            (Resolve-Path $zipPath).Path
          )

          try {
            if ($archive.Entries.Count -eq 0) {
              throw "Managed solution ZIP contains no files."
            }
          }
          finally {
            $archive.Dispose()
          }

          Write-Host "Managed solution ZIP verified."
          Write-Host "File: $zipPath"
          Write-Host "Size: $($file.Length) bytes"

      # Initializes the tooling required by Power Platform Actions.
      - name: Install Power Platform Tools
        uses: microsoft/powerplatform-actions/actions-install@v1

      # This action authenticates using the supplied service principal.
      - name: Import managed solution into Production
        uses: microsoft/powerplatform-actions/import-solution@v1
        with:
          environment-url: ${{ secrets.PROD_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_APP_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}
          solution-file: ${{ env.SOLUTION_ZIP }}
          use-deployment-settings-file: true
          deployment-settings-file: deployment-settings/prod.json
          publish-changes: true
          run-asynchronously: true
          max-async-wait-time: 60

      - name: Deployment summary
        if: success()
        shell: bash
        env:
          BUILD_RUN_ID: ${{ needs.resolve-build.outputs.build_run_id }}
        run: |
          {
            echo "## Production Deployment Successful"
            echo ""
            echo "- **Solution:** $SOLUTION_NAME"
            echo "- **Build Run ID:** $BUILD_RUN_ID"
            echo "- **Artifact:** managed-solution"
            echo "- **Environment:** Production"
            echo ""
            echo "The managed solution was deployed from the selected build artifact."
          } >> "$GITHUB_STEP_SUMMARY"

The build run ID is optional by design. Leave it blank and the workflow automatically fetches the latest successful build from main, which is the quickest path when nothing has been merged since the QA deployment. If newer builds have been created since QA testing, enter the run ID that QA actually tested. Either way, the approver can confirm the selected build in the build review summary before approving. The same artifact should move from QA to Production; only the target environment’s deployment settings differ.

18.Workflow 6 — CodeQL analysis

File
.github/workflows/codeql.yml
Purpose
Scan GitHub Actions workflow files for supported security issues.

This repository primarily contains Power Platform solution files, deployment settings, and GitHub Actions workflows. The current CodeQL configuration therefore analyzes the actions language only. It does not attempt JavaScript/TypeScript or C# analysis. If you later add PCF controls, JavaScript/TypeScript projects, or C# plugins, update the CodeQL configuration to include those languages and ensure the repository contains source code for them.

The workflow runs on pushes to main, pull requests targeting main, a weekly schedule, and manual dispatch. The schedule 30 5 * * 1 means Monday at 05:30 UTC (11:00 AM India Standard Time).

Workflow code

.github/workflows/codeql.yml
name: CodeQL Security Analysis

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
  schedule:
    - cron: "30 5 * * 1"
  workflow_dispatch:

permissions:
  contents: read
  security-events: write
  actions: read

jobs:
  analyze:
    name: Analyze GitHub Actions
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v5

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v4
        with:
          languages: actions

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v4

Review the CodeQL workflow run in GitHub → Actions → CodeQL Security Analysis. A successful run confirms that the configured Actions analysis completed; it does not replace Power Platform Solution Checker or validate the business behavior of the solution. If GitHub Code Security’s default setup is also enabled, check whether it runs a separate analysis configuration.

19.End-to-end release process

Once the repository and workflows are configured, use this process for each release.

A. Develop and export

  1. Make the required changes in Dev.
  2. Test the changes in Dev.
  3. Run export-dev-solution.yml.
  4. Review the generated export branch and pull request.
  5. Confirm the unpacked source changes match the intended solution updates.

B. Validate and merge

  1. Wait for build-and-check.yml to complete on the PR.
  2. Review the Solution Checker results and any validation output.
  3. Resolve issues and update the PR as needed.
  4. Merge only when the required checks pass and the review process is complete.

C. Build the release artifact

  1. Run build-managed-solution.yml, or let the configured main trigger run.
  2. Confirm that the managed-solution artifact was uploaded.
  3. Record the build run ID and source revision.

D. Deploy and test in QA

  1. Run deploy-qa.yml.
  2. Select the intended managed build according to the workflow’s inputs.
  3. Confirm that the solution imports successfully into QA.
  4. Run functional and integration tests for the solution.
  5. Record the build run ID and QA test outcome.

E. Promote to Production

  1. Run deploy-prod.yml.
  2. Leave the build run ID blank to use the latest successful build, or enter the run ID that passed QA if newer builds exist.
  3. Obtain the required Production environment approval.
  4. Confirm that the managed solution imports successfully.
  5. Perform Production smoke tests and record the release result.

20.Final validation checklist

Use this checklist after initial setup and during the first complete release.

Environment and authentication6 items

GitHub configuration7 items

Source, settings, and build9 items

QA and Production release9 items

21.Troubleshooting workflow failures

When a workflow fails, open the specific GitHub Actions run and identify the failed job and step. Use the step logs to distinguish authentication, permission, source validation, packaging, artifact, and import problems.

Typical areas to inspect include:

  • Authentication: environment URL, tenant ID, client ID, client secret validity, and application-user access.
  • GitHub branch/PR creation: PAT expiry, repository scope, Contents permission, and Pull requests permission.
  • PR validation: solution folder path, workflow trigger, JSON syntax, packaging output, and Solution Checker logs.
  • Artifact build: source path, solution unique name, whether the source was unpacked with both package types, managed packaging, and the managed-solution artifact name.
  • QA/Production import: selected build run ID, artifact contents, deployment settings, connection references, environment variables, dependencies, and target-environment permissions.

Correct the underlying issue and rerun the appropriate workflow. Keep the QA-tested build identifier for the Production release.

22.Recommended official documentation

Conclusion

This setup establishes a practical ALM lifecycle for Power Platform: export Dev changes into source control, review and validate them through pull requests, build a managed solution from the reviewed source, deploy that artifact to QA, and promote the same tested artifact to Production after approval.

The key to repeatable releases is keeping the responsibilities clear: Dev is where changes are made, GitHub is where source changes are reviewed, the managed artifact is what gets deployed, deployment settings provide target-specific configuration, and QA evidence plus Production approval control the final release.

Start with the sample EmployeeALMDemo solution as a POC, replace the example identifiers and dummy environment URLs with your own values, and test each workflow in your own tenant before relying on it for releases.