feat: add CI/CD pipeline for CodeApp deployment and management
This commit is contained in:
@@ -0,0 +1,185 @@
|
|||||||
|
trigger:
|
||||||
|
branches:
|
||||||
|
include:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
include:
|
||||||
|
- CodeApp_NeonReactor/src/**
|
||||||
|
- CodeApp_NeonReactor/public/**
|
||||||
|
- CodeApp_NeonReactor/index.html
|
||||||
|
- CodeApp_NeonReactor/power.config.json
|
||||||
|
|
||||||
|
pool:
|
||||||
|
vmImage: 'ubuntu-latest'
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- stage: Build
|
||||||
|
displayName: Build App
|
||||||
|
jobs:
|
||||||
|
- job: Build
|
||||||
|
displayName: Build React App
|
||||||
|
steps:
|
||||||
|
- task: UseNode@1
|
||||||
|
inputs:
|
||||||
|
version: '$(NODE_VERSION)'
|
||||||
|
displayName: 'Install Node.js'
|
||||||
|
|
||||||
|
- script: |
|
||||||
|
npm ci
|
||||||
|
displayName: 'Install dependencies'
|
||||||
|
workingDirectory: '$(CODEAPP_DIR)'
|
||||||
|
|
||||||
|
- script: |
|
||||||
|
npm run build
|
||||||
|
displayName: 'Build application'
|
||||||
|
workingDirectory: '$(CODEAPP_DIR)'
|
||||||
|
|
||||||
|
- stage: Deploy
|
||||||
|
displayName: Deploy to Power Platform
|
||||||
|
dependsOn: Build
|
||||||
|
condition: succeeded()
|
||||||
|
jobs:
|
||||||
|
- job: Deploy
|
||||||
|
displayName: Deploy Code App
|
||||||
|
steps:
|
||||||
|
- checkout: self
|
||||||
|
persistCredentials: true
|
||||||
|
|
||||||
|
- task: UseNode@1
|
||||||
|
inputs:
|
||||||
|
version: '$(NODE_VERSION)'
|
||||||
|
displayName: 'Install Node.js'
|
||||||
|
|
||||||
|
- script: |
|
||||||
|
npm ci
|
||||||
|
displayName: 'Install dependencies'
|
||||||
|
workingDirectory: '$(CODEAPP_DIR)'
|
||||||
|
|
||||||
|
- script: |
|
||||||
|
npm run build
|
||||||
|
displayName: 'Build application'
|
||||||
|
workingDirectory: '$(CODEAPP_DIR)'
|
||||||
|
|
||||||
|
- script: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
dotnet tool install --global Microsoft.PowerApps.CLI.Tool --version "$(PAC_CLI_VERSION)"
|
||||||
|
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||||
|
|
||||||
|
if [ -z "$(ENVIRONMENT_URL)" ]; then
|
||||||
|
echo "ENVIRONMENT_URL is empty. Set it to your Dataverse org URL (example: https://orgname.crm.dynamics.com)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(SERVICE_USERNAME)" ]; then
|
||||||
|
echo "SERVICE_USERNAME is empty. Set it in Azure DevOps Pipeline settings."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(SERVICE_PASSWORD)" ]; then
|
||||||
|
echo "SERVICE_PASSWORD is empty. Set it as a secret variable in Azure DevOps Pipeline settings."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(PAC_CLI_VERSION)" ]; then
|
||||||
|
echo "PAC_CLI_VERSION is empty. Set it as a pipeline variable in Azure DevOps."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "./dist" ]; then
|
||||||
|
echo "Build output ./dist not found. Ensure the deploy job runs npm run build or downloads build artifacts."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
pac auth clear
|
||||||
|
pac auth create --name "CI" --username "$(SERVICE_USERNAME)" --password "$(SERVICE_PASSWORD)" --tenant "$(TENANT_ID)"
|
||||||
|
pac org select --environment "$(ENVIRONMENT_URL)"
|
||||||
|
|
||||||
|
PAC_ACCESS_LOG="$(mktemp)"
|
||||||
|
pac code list 2>&1 | tee "$PAC_ACCESS_LOG"
|
||||||
|
if grep -Eiq "HTTP error status:\s*403|does not have permission to access the path|Forbidden" "$PAC_ACCESS_LOG"; then
|
||||||
|
echo "PAC access check failed for this service user in target environment."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_APP_ID="$(node -e "const fs=require('fs');const c=JSON.parse(fs.readFileSync('power.config.json','utf8'));console.log(c.appId ?? '')")"
|
||||||
|
CURRENT_APP_NAME="$(node -e "const fs=require('fs');const c=JSON.parse(fs.readFileSync('power.config.json','utf8'));console.log(c.appDisplayName)")"
|
||||||
|
|
||||||
|
if [ -z "$CURRENT_APP_ID" ] || [ "$CURRENT_APP_ID" = "null" ]; then
|
||||||
|
EXISTING_APP_ID="$(awk -v app="$CURRENT_APP_NAME" 'index($0, app)>0 && $1 ~ /^[0-9a-fA-F-]{36}$/ {print $1; exit}' "$PAC_ACCESS_LOG")"
|
||||||
|
if [ -n "$EXISTING_APP_ID" ]; then
|
||||||
|
echo "Resolved existing appId '$EXISTING_APP_ID' from app display name '$CURRENT_APP_NAME'."
|
||||||
|
node -e "const fs=require('fs');const p='power.config.json';const c=JSON.parse(fs.readFileSync(p,'utf8'));c.appId='$EXISTING_APP_ID';fs.writeFileSync(p, JSON.stringify(c,null,2)+'\\n');"
|
||||||
|
CURRENT_APP_ID="$EXISTING_APP_ID"
|
||||||
|
else
|
||||||
|
echo "No existing app found for '$CURRENT_APP_NAME'. pac code push will create one."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
max_attempts=3
|
||||||
|
attempt=1
|
||||||
|
push_succeeded=0
|
||||||
|
app_not_found_recovery_done=0
|
||||||
|
|
||||||
|
while [ "$attempt" -le "$max_attempts" ]; do
|
||||||
|
echo "PAC push attempt $attempt/$max_attempts"
|
||||||
|
PAC_PUSH_LOG="$(mktemp)"
|
||||||
|
pac code push 2>&1 | tee "$PAC_PUSH_LOG"
|
||||||
|
|
||||||
|
if grep -Eiq "HTTP error status:\s*403|does not have permission to access the path|Forbidden" "$PAC_PUSH_LOG"; then
|
||||||
|
echo "PAC deployment failed due to insufficient service user permissions in the target environment."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -Eiq "ApplicationNotFound" "$PAC_PUSH_LOG"; then
|
||||||
|
if [ "$app_not_found_recovery_done" -eq 0 ] && [ -n "$CURRENT_APP_ID" ] && [ "$CURRENT_APP_ID" != "null" ]; then
|
||||||
|
echo "Configured appId '$CURRENT_APP_ID' was not found. Clearing appId and retrying once as app creation flow."
|
||||||
|
node -e "const fs=require('fs');const p='power.config.json';const c=JSON.parse(fs.readFileSync(p,'utf8'));c.appId=null;fs.writeFileSync(p, JSON.stringify(c,null,2)+'\\n');"
|
||||||
|
CURRENT_APP_ID=""
|
||||||
|
app_not_found_recovery_done=1
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -Eiq "HTTP error status:\s*5[0-9]{2}|InternalServerError" "$PAC_PUSH_LOG"; then
|
||||||
|
if [ "$attempt" -lt "$max_attempts" ]; then
|
||||||
|
echo "PAC deployment hit a transient server error (5xx). Retrying in 15 seconds..."
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 15
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -Eiq "HTTP error status:" "$PAC_PUSH_LOG"; then
|
||||||
|
echo "PAC deployment failed. See HTTP error details in the logs above."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
push_succeeded=1
|
||||||
|
break
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$push_succeeded" -ne 1 ]; then
|
||||||
|
echo "PAC deployment did not succeed after $max_attempts attempts."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PUSH_APP_ID="$(sed -nE 's#.*app/([0-9a-fA-F-]{36}).*#\1#p' "$PAC_PUSH_LOG" | head -n1)"
|
||||||
|
if [ -n "$PUSH_APP_ID" ] && ([ -z "$CURRENT_APP_ID" ] || [ "$CURRENT_APP_ID" = "null" ]); then
|
||||||
|
echo "Persisting newly created appId '$PUSH_APP_ID' into power.config.json."
|
||||||
|
node -e "const fs=require('fs');const p='power.config.json';const c=JSON.parse(fs.readFileSync(p,'utf8'));c.appId='$PUSH_APP_ID';fs.writeFileSync(p, JSON.stringify(c,null,2)+'\\n');"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git diff --quiet -- power.config.json; then
|
||||||
|
echo "Committing updated power.config.json back to repository."
|
||||||
|
git config user.email "azure-pipelines-bot@local"
|
||||||
|
git config user.name "azure-pipelines-bot"
|
||||||
|
git add power.config.json
|
||||||
|
git commit -m "ci: persist CodeApp appId [skip ci]"
|
||||||
|
git push origin HEAD:master
|
||||||
|
else
|
||||||
|
echo "No appId changes to persist."
|
||||||
|
fi
|
||||||
|
displayName: 'Authenticate and Deploy to Power Platform'
|
||||||
|
workingDirectory: '$(CODEAPP_DIR)'
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
trigger: none
|
||||||
|
pr: none
|
||||||
|
|
||||||
|
pool:
|
||||||
|
vmImage: 'ubuntu-latest'
|
||||||
|
|
||||||
|
variables:
|
||||||
|
PAC_CLI_VERSION: '2.8.1'
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- stage: BranchGuard
|
||||||
|
displayName: Validate Source Branch
|
||||||
|
jobs:
|
||||||
|
- job: ValidateMaster
|
||||||
|
displayName: Ensure run is from master
|
||||||
|
steps:
|
||||||
|
- script: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "Build.SourceBranch=$(Build.SourceBranch)"
|
||||||
|
if [ "$(Build.SourceBranch)" != "refs/heads/master" ]; then
|
||||||
|
echo "This pipeline can only run from refs/heads/master."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
displayName: 'Fail if branch is not master'
|
||||||
|
|
||||||
|
- stage: ExportSolution
|
||||||
|
displayName: Export Solution from Source
|
||||||
|
dependsOn: BranchGuard
|
||||||
|
condition: succeeded()
|
||||||
|
jobs:
|
||||||
|
- job: Export
|
||||||
|
displayName: Export Managed Solution
|
||||||
|
steps:
|
||||||
|
- task: UseNode@1
|
||||||
|
inputs:
|
||||||
|
version: '22.x'
|
||||||
|
displayName: 'Install Node.js'
|
||||||
|
|
||||||
|
- script: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
dotnet tool install --global Microsoft.PowerApps.CLI.Tool --version "$(PAC_CLI_VERSION)"
|
||||||
|
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||||
|
|
||||||
|
if [ -z "$(SOLUTION_UNIQUE_NAME)" ]; then
|
||||||
|
echo "SOLUTION_UNIQUE_NAME is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(SOURCE_ENVIRONMENT_URL)" ]; then
|
||||||
|
echo "SOURCE_ENVIRONMENT_URL is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(SOURCE_USERNAME)" ]; then
|
||||||
|
echo "SOURCE_USERNAME is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(SOURCE_PASSWORD)" ]; then
|
||||||
|
echo "SOURCE_PASSWORD is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(SOURCE_TENANT_ID)" ]; then
|
||||||
|
echo "SOURCE_TENANT_ID is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
pac auth clear
|
||||||
|
pac auth create --name "Source" --username "$(SOURCE_USERNAME)" --password "$(SOURCE_PASSWORD)" --tenant "$(SOURCE_TENANT_ID)"
|
||||||
|
pac org select --environment "$(SOURCE_ENVIRONMENT_URL)"
|
||||||
|
|
||||||
|
mkdir -p "$(Build.ArtifactStagingDirectory)/solution"
|
||||||
|
EXPORT_PATH="$(Build.ArtifactStagingDirectory)/solution/$(SOLUTION_UNIQUE_NAME)_managed.zip"
|
||||||
|
|
||||||
|
pac solution export \
|
||||||
|
--name "$(SOLUTION_UNIQUE_NAME)" \
|
||||||
|
--path "$EXPORT_PATH" \
|
||||||
|
--managed \
|
||||||
|
--overwrite
|
||||||
|
|
||||||
|
echo "Exported managed solution to: $EXPORT_PATH"
|
||||||
|
displayName: 'Export managed solution from source'
|
||||||
|
|
||||||
|
- task: PublishPipelineArtifact@1
|
||||||
|
inputs:
|
||||||
|
targetPath: '$(Build.ArtifactStagingDirectory)/solution'
|
||||||
|
artifact: 'solution-package'
|
||||||
|
publishLocation: 'pipeline'
|
||||||
|
displayName: 'Publish exported solution artifact'
|
||||||
|
|
||||||
|
- stage: Approval
|
||||||
|
displayName: Approval Before PROD Import
|
||||||
|
dependsOn: ExportSolution
|
||||||
|
condition: succeeded()
|
||||||
|
jobs:
|
||||||
|
- job: WaitForApproval
|
||||||
|
displayName: Manual Approval
|
||||||
|
pool: server
|
||||||
|
timeoutInMinutes: 4320
|
||||||
|
steps:
|
||||||
|
- task: ManualValidation@0
|
||||||
|
timeoutInMinutes: 1440
|
||||||
|
inputs:
|
||||||
|
notifyUsers: ''
|
||||||
|
instructions: |
|
||||||
|
Approve to import solution $(SOLUTION_UNIQUE_NAME) into PROD.
|
||||||
|
Run context:
|
||||||
|
- Source ref: $(Build.SourceBranch)
|
||||||
|
- Source ref name: $(Build.SourceBranchName)
|
||||||
|
- Commit: $(Build.SourceVersion)
|
||||||
|
- Run number: $(Build.BuildNumber)
|
||||||
|
Validate change ticket, release notes, and deployment window before approving.
|
||||||
|
onTimeout: reject
|
||||||
|
|
||||||
|
- stage: ImportProd
|
||||||
|
displayName: Import Solution into PROD
|
||||||
|
dependsOn: Approval
|
||||||
|
condition: succeeded()
|
||||||
|
jobs:
|
||||||
|
- job: Import
|
||||||
|
displayName: Import Managed Solution to PROD
|
||||||
|
steps:
|
||||||
|
- task: UseNode@1
|
||||||
|
inputs:
|
||||||
|
version: '22.x'
|
||||||
|
displayName: 'Install Node.js'
|
||||||
|
|
||||||
|
- task: DownloadPipelineArtifact@2
|
||||||
|
inputs:
|
||||||
|
artifact: 'solution-package'
|
||||||
|
path: '$(Pipeline.Workspace)/solution-package'
|
||||||
|
displayName: 'Download exported solution artifact'
|
||||||
|
|
||||||
|
- script: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
dotnet tool install --global Microsoft.PowerApps.CLI.Tool --version "$(PAC_CLI_VERSION)"
|
||||||
|
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||||
|
|
||||||
|
IMPORT_PATH="$(Pipeline.Workspace)/solution-package/$(SOLUTION_UNIQUE_NAME)_managed.zip"
|
||||||
|
if [ ! -f "$IMPORT_PATH" ]; then
|
||||||
|
echo "Managed solution package not found at: $IMPORT_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(PROD_ENVIRONMENT_URL)" ]; then
|
||||||
|
echo "PROD_ENVIRONMENT_URL is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(PROD_USERNAME)" ]; then
|
||||||
|
echo "PROD_USERNAME is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(PROD_PASSWORD)" ]; then
|
||||||
|
echo "PROD_PASSWORD is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET_TENANT_ID_VALUE="$(TARGET_TENANT_ID)"
|
||||||
|
if [ -z "$TARGET_TENANT_ID_VALUE" ]; then
|
||||||
|
TARGET_TENANT_ID_VALUE="$(PROD_TENANT_ID)"
|
||||||
|
fi
|
||||||
|
if [ -z "$TARGET_TENANT_ID_VALUE" ]; then
|
||||||
|
echo "TARGET_TENANT_ID is empty. Set TARGET_TENANT_ID (preferred) or PROD_TENANT_ID."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
pac auth clear
|
||||||
|
pac auth create --name "Prod" --username "$(PROD_USERNAME)" --password "$(PROD_PASSWORD)" --tenant "$TARGET_TENANT_ID_VALUE"
|
||||||
|
pac org select --environment "$(PROD_ENVIRONMENT_URL)"
|
||||||
|
|
||||||
|
pac solution import --path "$IMPORT_PATH" --publish-changes
|
||||||
|
echo "Imported solution $(SOLUTION_UNIQUE_NAME) into PROD."
|
||||||
|
displayName: 'Import managed solution into PROD'
|
||||||
Reference in New Issue
Block a user