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
pacand Microsoft Graph SDKs. TheCrm/PowerAppsmodules 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, ordod. 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. IfImport-CrmSolutionAsyncis unavailable, fall back to the synchronousImport-CrmSolutioncmdlet.
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 recordBuild— incremented on every successful Dev exportRevision— 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
OnApprovalStartedflow 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.
Related Playbooks
- Portal Walkthrough — PPAC and Copilot Studio configuration
- Verification & Testing — Evidence collection, rollback drills, A/B testing
- Troubleshooting — Common failures with pipelines, approvals, and delegated deployments
Updated: April 2026 | Version: v1.4.0