Skip to content

Control 2.3: Change Management and Release Planning — PowerShell Setup

Read the FSI PowerShell baseline first

Before running any command in this playbook, read the PowerShell Authoring Baseline for FSI Implementations. It is the canonical source for module version pinning, sovereign-cloud (GCC / GCC High / DoD) endpoints, mutation safety (-WhatIf / SupportsShouldProcess), Dataverse compatibility, and SHA-256 evidence emission. Snippets below show abbreviated patterns; the baseline is authoritative.

Automation scripts for Control 2.3: Change Management and Release Planning.

Audience: Power Platform Admins and pipeline engineers in US financial services organizations.


Module Inventory

Module / CLI Purpose Notes
Microsoft.PowerApps.Administration.PowerShell Tenant + environment admin operations Pin per FSI baseline
Microsoft.PowerApps.PowerShell Maker-side operations Pin per FSI baseline
Microsoft.Xrm.Tooling.CrmConnector.PowerShell Solution export / import via SDK Required v3.3+ for Import-CrmSolutionAsync
Microsoft.Graph.Authentication + Microsoft.Graph.Reports Service Communications API (Message Center) Use the Graph SDK; do not script raw token acquisition
Power Platform CLI (pac) Modern, recommended for new automation Microsoft has signaled pac as the long-term path; the legacy Crm cmdlets remain supported

FSI guidance: New automation should be authored against pac and Microsoft Graph SDKs. The Crm/PowerApps modules below are retained because they remain in widespread use and the FSI baseline currently pins them. Track the baseline for migration timelines.


Prerequisites

# Install required modules (run as administrator; pin versions per FSI baseline)
Install-Module -Name Microsoft.PowerApps.Administration.PowerShell -Force -AllowClobber
Install-Module -Name Microsoft.PowerApps.PowerShell -Force -AllowClobber
Install-Module -Name Microsoft.Xrm.Tooling.CrmConnector.PowerShell -Force -AllowClobber
Install-Module -Name Microsoft.Graph.Authentication -Force
Install-Module -Name Microsoft.Graph.Reports -Force

# Interactive sign-in (developer / break-glass use only)
Add-PowerAppsAccount

# Service principal sign-in (production automation; recommended)
# Store secrets in Azure Key Vault or equivalent; never inline in scripts
# $appId    = (Get-Secret -Name 'fsi-pp-pipeline-spn-appid'    -AsPlainText)
# $secret   = (Get-Secret -Name 'fsi-pp-pipeline-spn-secret'   -AsPlainText)
# $tenantId = (Get-Secret -Name 'fsi-tenant-id'                -AsPlainText)
# Add-PowerAppsAccount -ApplicationId $appId -ClientSecret $secret -TenantID $tenantId

Sovereign-cloud note: For GCC, GCC High, and DoD tenants, override the endpoint via Add-PowerAppsAccount -Endpoint usgov (GCC), usgovhigh, or dod. See the FSI PowerShell baseline for the canonical endpoint table.


1. Solution Export

Note: Copilot Studio solution exports include topics, agent metadata, and packaged knowledge sources. Externally hosted knowledge sources, files referenced by URL, and tenant-level connector configurations are not part of the solution boundary and must be captured in a parallel inventory.

[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [Parameter(Mandatory = $true)] [string] $EnvironmentUrl,
    [Parameter(Mandatory = $true)] [string] $SolutionUniqueName,
    [Parameter(Mandatory = $true)] [string] $OutputDirectory,
    [switch] $Managed
)

$ErrorActionPreference = 'Stop'

$conn = Connect-CrmOnline -ServerUrl $EnvironmentUrl -Interactive
$suffix = if ($Managed) { '_managed' } else { '_unmanaged' }
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$outFile = Join-Path $OutputDirectory ("{0}{1}_{2}.zip" -f $SolutionUniqueName, $suffix, $timestamp)

if ($PSCmdlet.ShouldProcess($EnvironmentUrl, "Export solution $SolutionUniqueName to $outFile")) {
    Export-CrmSolution -conn $conn -SolutionName $SolutionUniqueName -SolutionFilePath $outFile -Managed:$Managed.IsPresent
    $hash = (Get-FileHash -Path $outFile -Algorithm SHA256).Hash
    [PSCustomObject]@{
        SolutionName = $SolutionUniqueName
        File         = $outFile
        Managed      = [bool]$Managed
        SizeBytes    = (Get-Item $outFile).Length
        SHA256       = $hash
        ExportedAt   = (Get-Date).ToString('o')
        ExportedBy   = $env:USERNAME
    } | ConvertTo-Json -Depth 4 | Out-File -FilePath ($outFile + '.evidence.json') -Encoding utf8
    Write-Output "Exported $outFile (SHA256 $hash)"
}

The .evidence.json sidecar is the artifact you commit to source control alongside the solution zip; the SHA-256 hash supports SEC 17a-4 integrity expectations for retained records.


2. Solution Import

[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [Parameter(Mandatory = $true)] [string] $TargetEnvironmentUrl,
    [Parameter(Mandatory = $true)] [string] $SolutionFilePath,
    [string] $ChangeRequestId
)

$ErrorActionPreference = 'Stop'
if (-not (Test-Path $SolutionFilePath)) { throw "Solution file not found: $SolutionFilePath" }

# Verify hash against evidence sidecar before import
$evidencePath = "$SolutionFilePath.evidence.json"
if (Test-Path $evidencePath) {
    $evidence = Get-Content $evidencePath -Raw | ConvertFrom-Json
    $currentHash = (Get-FileHash -Path $SolutionFilePath -Algorithm SHA256).Hash
    if ($currentHash -ne $evidence.SHA256) {
        throw "Hash mismatch for $SolutionFilePath. Expected $($evidence.SHA256), got $currentHash. Aborting import."
    }
}

$conn = Connect-CrmOnline -ServerUrl $TargetEnvironmentUrl -Interactive

if ($PSCmdlet.ShouldProcess($TargetEnvironmentUrl, "Import $SolutionFilePath (CR $ChangeRequestId)")) {
    # Microsoft.Xrm.Tooling.CrmConnector.PowerShell v3.3+ required for async
    $job = Import-CrmSolutionAsync -conn $conn -SolutionFilePath $SolutionFilePath `
        -OverwriteUnmanagedCustomizations $true -PublishWorkflows $true
    Write-Output "Import job started: $($job.ImportJobId) for CR $ChangeRequestId"
    return $job
}

Verify the module version before running: Get-Module Microsoft.Xrm.Tooling.CrmConnector.PowerShell -ListAvailable. If Import-CrmSolutionAsync is unavailable, fall back to the synchronous Import-CrmSolution cmdlet.


3. Solution Version Management

function Get-FsiSolutionVersion {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string] $EnvironmentUrl,
        [Parameter(Mandatory = $true)] [string] $SolutionUniqueName
    )
    $conn = Connect-CrmOnline -ServerUrl $EnvironmentUrl -Interactive
    $rec = Get-CrmRecords -conn $conn -EntityLogicalName solution `
        -FilterAttribute uniquename -FilterOperator eq -FilterValue $SolutionUniqueName -Fields version
    if (-not $rec.CrmRecords) { throw "Solution not found: $SolutionUniqueName" }
    return $rec.CrmRecords[0].version
}

function Set-FsiSolutionVersion {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory = $true)] [string] $EnvironmentUrl,
        [Parameter(Mandatory = $true)] [string] $SolutionUniqueName,
        [Parameter(Mandatory = $true)] [string] $NewVersion
    )
    if ($NewVersion -notmatch '^\d+\.\d+\.\d+\.\d+$') {
        throw "Version must be in [Major].[Minor].[Build].[Revision] format. Got: $NewVersion"
    }
    $conn = Connect-CrmOnline -ServerUrl $EnvironmentUrl -Interactive
    $rec = Get-CrmRecords -conn $conn -EntityLogicalName solution `
        -FilterAttribute uniquename -FilterOperator eq -FilterValue $SolutionUniqueName -Fields solutionid
    $solutionId = $rec.CrmRecords[0].solutionid
    if ($PSCmdlet.ShouldProcess("$SolutionUniqueName ($EnvironmentUrl)", "Set version to $NewVersion")) {
        Set-CrmRecord -conn $conn -EntityLogicalName solution -Fields @{
            solutionid = $solutionId
            version    = $NewVersion
        }
    }
}

Conventions:

  • Major.Minor — incremented for documented business changes; mapped to a CAB-approved change record
  • Build — incremented on every successful Dev export
  • Revision — incremented on every pipeline run; provides a unique deployment artifact identifier

4. Configuration Snapshot

Capture the agent's current configuration before every change. The snapshot is the rollback reference for in-place changes and the diff baseline for solution-packaged changes.

[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [Parameter(Mandatory = $true)] [string] $AgentId,
    [Parameter(Mandatory = $true)] [string] $ChangeRequestId,
    [string] $OutputRoot = '.\agent-snapshots'
)

$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$snapshotDir = Join-Path $OutputRoot (Join-Path $AgentId $timestamp)
if ($PSCmdlet.ShouldProcess($snapshotDir, 'Create snapshot directory')) {
    New-Item -ItemType Directory -Path $snapshotDir -Force | Out-Null
}

# Replace the placeholder values below with calls to your inventory source
# (Copilot Studio export, Dataverse query, or Graph beta agent endpoints).
$snapshot = [ordered]@{
    metadata = [ordered]@{
        agentId          = $AgentId
        changeRequestId  = $ChangeRequestId
        snapshotDate     = (Get-Date).ToString('o')
        snapshotBy       = $env:USERNAME
        tenantId         = $env:AZURE_TENANT_ID
    }
    configuration = [ordered]@{
        systemPrompt        = '<<populate from Copilot Studio export>>'
        topics              = @()
        knowledgeSources    = @()
        connectors          = @()
        actions             = @()
        settings            = [ordered]@{
            authenticationMode  = '<<populate>>'
            fallbackBehavior    = '<<populate>>'
            contentModeration   = '<<populate>>'
            sharingScope        = '<<populate>>'
        }
    }
}

$snapshotFile = Join-Path $snapshotDir 'agent-config.json'
$snapshot | ConvertTo-Json -Depth 12 | Out-File -FilePath $snapshotFile -Encoding utf8

$hash = (Get-FileHash -Path $snapshotFile -Algorithm SHA256).Hash
$evidence = [ordered]@{
    AgentId          = $AgentId
    ChangeRequestId  = $ChangeRequestId
    SnapshotFile     = $snapshotFile
    SHA256           = $hash
    CapturedAt       = (Get-Date).ToString('o')
}
$evidence | ConvertTo-Json | Out-File -FilePath ($snapshotFile + '.evidence.json') -Encoding utf8

Write-Output "Snapshot: $snapshotFile (SHA256 $hash)"
Write-Output 'Commit the snapshot directory to source control before proceeding with the change.'

5. Microsoft 365 Message Center — Delta Pull

Use the Microsoft Graph Service Communications API to pull Message Center messages on a schedule. Delta queries return only changes since the last poll, which keeps load low and supports near-real-time triage.

[CmdletBinding()]
param(
    [string] $StateFile = '.\.message-center-delta.txt',
    [string[]] $Services = @('Microsoft Copilot', 'Power Platform', 'Power Automate', 'Common Data Service', 'SharePoint Online')
)

# Connect with delegated or app-only context. ServiceMessage.Read.All is required.
Connect-MgGraph -Scopes 'ServiceMessage.Read.All' -NoWelcome

$baseUri = 'https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/messages'
$uri = if (Test-Path $StateFile) { Get-Content $StateFile -Raw } else { "$baseUri/delta" }

$results = @()
do {
    $page = Invoke-MgGraphRequest -Method GET -Uri $uri
    if ($page.value) { $results += $page.value }
    $uri = $page.'@odata.nextLink'
} while ($uri)

if ($page.'@odata.deltaLink') {
    $page.'@odata.deltaLink' | Out-File -FilePath $StateFile -Encoding ascii
}

$relevant = $results | Where-Object {
    $msg = $_
    ($msg.services | Where-Object { $Services -contains $_ }).Count -gt 0
}

$relevant | ForEach-Object {
    [PSCustomObject]@{
        Id           = $_.id
        Title        = $_.title
        Severity     = $_.severity
        Category     = $_.category
        ActionType   = $_.actionType
        Services     = ($_.services -join ', ')
        StartDate    = $_.startDateTime
        EndDate      = $_.endDateTime
        LastModified = $_.lastModifiedDateTime
        WebUrl       = $_.viewPointBy.WebUrl
    }
} | ConvertTo-Json -Depth 5

Route the relevant messages into your change-intake queue (Dataverse table, ServiceNow, or Azure DevOps work items). See the Message Center Monitor companion solution for a production implementation.


6. Orchestrator: End-to-End Pipeline Deployment

function Invoke-FsiPipelineDeployment {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory = $true)] [string] $SolutionUniqueName,
        [Parameter(Mandatory = $true)] [string] $SourceEnvironmentUrl,
        [Parameter(Mandatory = $true)] [string] $TargetEnvironmentUrl,
        [Parameter(Mandatory = $true)] [ValidateSet('Zone1', 'Zone2', 'Zone3')] [string] $TargetZone,
        [Parameter(Mandatory = $true)] [string] $ChangeRequestId,
        [string] $LogRoot = 'C:\Logs\FSI-Deployments'
    )

    $ErrorActionPreference = 'Stop'
    $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
    if (-not (Test-Path $LogRoot)) { New-Item -ItemType Directory -Path $LogRoot | Out-Null }
    $logFile = Join-Path $LogRoot ("Deploy_{0}_{1}.log" -f $SolutionUniqueName, $timestamp)

    function Write-Log { param($m) "$((Get-Date).ToString('o')) $m" | Tee-Object -FilePath $logFile -Append }

    Write-Log "Start: CR=$ChangeRequestId Zone=$TargetZone Source=$SourceEnvironmentUrl Target=$TargetEnvironmentUrl"

    try {
        # 1. Increment Build version in source
        $current = Get-FsiSolutionVersion -EnvironmentUrl $SourceEnvironmentUrl -SolutionUniqueName $SolutionUniqueName
        $parts = $current.Split('.')
        if ($parts.Count -ne 4) { throw "Source version must be 4-part. Got: $current" }
        $parts[2] = [int]$parts[2] + 1
        $next = $parts -join '.'
        Set-FsiSolutionVersion -EnvironmentUrl $SourceEnvironmentUrl -SolutionUniqueName $SolutionUniqueName -NewVersion $next
        Write-Log "Version $current -> $next"

        # 2. Export managed
        $exportDir = Join-Path $LogRoot 'artifacts'
        if (-not (Test-Path $exportDir)) { New-Item -ItemType Directory -Path $exportDir | Out-Null }
        & "$PSScriptRoot\Export-FsiSolution.ps1" -EnvironmentUrl $SourceEnvironmentUrl `
            -SolutionUniqueName $SolutionUniqueName -OutputDirectory $exportDir -Managed
        $exported = Get-ChildItem -Path $exportDir -Filter "${SolutionUniqueName}_managed_*.zip" |
            Sort-Object LastWriteTime -Descending | Select-Object -First 1
        Write-Log "Exported: $($exported.FullName)"

        # 3. Zone 3 explicit pause for delegated-deployment / CAB acknowledgement
        if ($TargetZone -eq 'Zone3') {
            Write-Log 'Zone 3: pausing for delegated-deployment approval. Approval is captured by the Power Automate flow.'
            # In CI, this script should hand off to the pipeline approval gate
            # rather than block locally.
        }

        # 4. Import to target
        if ($PSCmdlet.ShouldProcess($TargetEnvironmentUrl, "Import $($exported.Name)")) {
            & "$PSScriptRoot\Import-FsiSolution.ps1" -TargetEnvironmentUrl $TargetEnvironmentUrl `
                -SolutionFilePath $exported.FullName -ChangeRequestId $ChangeRequestId
            Write-Log 'Import requested.'
        }

        # 5. Verify deployed version
        Start-Sleep -Seconds 30
        $deployed = Get-FsiSolutionVersion -EnvironmentUrl $TargetEnvironmentUrl -SolutionUniqueName $SolutionUniqueName
        if ($deployed -ne $next) {
            Write-Log "WARN: deployed=$deployed expected=$next"
        } else {
            Write-Log "PASS: deployed=$deployed"
        }

        return [PSCustomObject]@{
            Success         = $true
            Version         = $next
            ChangeRequestId = $ChangeRequestId
            ArtifactPath    = $exported.FullName
            LogFile         = $logFile
        }
    } catch {
        Write-Log "FAIL: $($_.Exception.Message)"
        Write-Log 'Rollback may be required. See verification-testing.md for the rollback runbook.'
        return [PSCustomObject]@{
            Success         = $false
            Error           = $_.Exception.Message
            LogFile         = $logFile
        }
    }
}

CI/CD note: In a production pipeline (Azure DevOps, GitHub Actions), the explicit Zone 3 pause should be replaced by the pipeline platform's own approval gate so the orchestrator does not block on interactive input. The Power Automate OnApprovalStarted flow remains authoritative for the change record.


7. List Solution Components (Inventory)

function Get-FsiSolutionComponents {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string] $EnvironmentUrl,
        [Parameter(Mandatory = $true)] [string] $SolutionUniqueName
    )
    $conn = Connect-CrmOnline -ServerUrl $EnvironmentUrl -Interactive
    $sol = Get-CrmRecords -conn $conn -EntityLogicalName solution `
        -FilterAttribute uniquename -FilterOperator eq -FilterValue $SolutionUniqueName -Fields solutionid
    $solutionId = $sol.CrmRecords[0].solutionid

    $components = Get-CrmRecords -conn $conn -EntityLogicalName solutioncomponent `
        -FilterAttribute solutionid -FilterOperator eq -FilterValue $solutionId `
        -Fields componenttype, objectid, rootcomponentbehavior

    $typeMap = @{
        1     = 'Entity'; 2 = 'Attribute'; 3 = 'Relationship'; 9 = 'OptionSet'
        29    = 'Workflow'; 59 = 'SystemForm'; 60 = 'WebResource'; 80 = 'ModelDrivenApp'
        300   = 'CanvasApp'; 371 = 'Connector'; 372 = 'EnvironmentVariableDefinition'
        380   = 'AIModel'; 10029 = 'CopilotStudioAgent'
    }

    $components.CrmRecords | ForEach-Object {
        [PSCustomObject]@{
            Type     = if ($typeMap.ContainsKey($_.componenttype)) { $typeMap[$_.componenttype] } else { "Type$($_.componenttype)" }
            ObjectId = $_.objectid
            Behavior = $_.rootcomponentbehavior
        }
    }
}

Use this inventory to confirm that every Copilot Studio agent (componenttype = 10029) and its associated topics, connectors, and environment variables are present in the solution before export.



Updated: April 2026 | Version: v1.4.0