diff --git a/pipeline-dev.yml b/pipeline-dev.yml new file mode 100644 index 0000000..314af14 --- /dev/null +++ b/pipeline-dev.yml @@ -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)' diff --git a/pipeline-prod.yml b/pipeline-prod.yml new file mode 100644 index 0000000..5a043a6 --- /dev/null +++ b/pipeline-prod.yml @@ -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' \ No newline at end of file