In my bitbucket repository, I enabled pipelines and added branch restrictions for security purposes.

Also I used Repository variables within in my bitbucket repository associated to my service principal set up in Azure app registration.

This CICD pipeline allows my team to merge code to our main branch to auto-deploy-install our extension in our BC cloud tenant.

I then created a bitbucket-pipelines.yml file:

# Bitbucket Pipeline for AL Deployment
# Runs only when code is merged or pushed to the 'main' branch

image: mcr.microsoft.com/powershell:latest

definitions:
  variables:
    AZURE_TENANT_ID: $AZURE_TENANT_ID
    AZURE_APP_ID: $AZURE_APP_ID
    AZURE_CLIENT_SECRET: $AZURE_CLIENT_SECRET
    ENVIRONMENT: $ENVIRONMENT
    COMPANY_ID: $COMPANY_ID

pipelines:
  branches:
    main:
      - step:
          name: 'Build and Deploy to Business Central'
          script:
            - echo "Detected push or merge to main — starting deployment..."
            # Set environment variables for deployment
            - export AZURE_TENANT_ID="$AZURE_TENANT_ID"
            - export AZURE_APP_ID="$AZURE_APP_ID"
            - export AZURE_CLIENT_SECRET="$AZURE_CLIENT_SECRET"
            - export ENVIRONMENT="$ENVIRONMENT"
            - export COMPANY_ID="$COMPANY_ID"
            - pwsh ./deploy.ps1

And created a deploy powershell script:

# Enable strict mode and error handling
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

try {
    # Verify environment variables
    if (-not $env:AZURE_TENANT_ID -or -not $env:AZURE_APP_ID -or -not $env:AZURE_CLIENT_SECRET) {
        throw "Required environment variables are missing. Ensure AZURE_TENANT_ID, AZURE_APP_ID, and AZURE_CLIENT_SECRET are set."
    }

    # Find the most recent .app file in the current directory
    $appFile = Get-ChildItem -Path . -Filter *.app | Sort-Object LastWriteTime -Descending | Select-Object -First 1

    if (-not $appFile) {
        throw "No .app file found in the current directory. Build may have failed."
    }

    Write-Host "Found app file: $($appFile.Name)"

    # Acquire OAuth token for Business Central API using environment variables
    $tenantId = $env:AZURE_TENANT_ID
    $clientId = $env:AZURE_APP_ID
    $clientSecret = $env:AZURE_CLIENT_SECRET
    $tokenUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"

    $body = @{
        client_id     = $clientId
        scope         = "https://api.businesscentral.dynamics.com/.default"
        client_secret = $clientSecret
        grant_type    = "client_credentials"
    }

    Write-Host "Acquiring OAuth token..."
    $response = Invoke-RestMethod -Method Post -Uri $tokenUrl -Body $body
    $token = $response.access_token
    Write-Host "OAuth token acquired successfully"

    $companyId = $env:COMPANY_ID
    $environment = $env:ENVIRONMENT

    # Upload .app to Business Central
    Write-Host "Uploading app to Business Central..."
    $uploadResponse = Invoke-WebRequest `
        -Method Patch `
        -Uri "https://api.businesscentral.dynamics.com/v2.0/$tenantId/$environment/api/microsoft/automation/v1.0/companies($companyId)/extensionUpload(0)/content" `
        -Headers @{
            "Authorization" = "Bearer $token"
            "If-Match" = "*"
        } `
        -ContentType "application/octet-stream" `
        -InFile $appFile.FullName

    Write-Host "App uploaded successfully with status code: $($uploadResponse.StatusCode)"
    Write-Host "Checking deployment status..."

    # Function to check deployment status
    function Get-DeploymentStatus {
        $statusUrl = "https://api.businesscentral.dynamics.com/v2.0/$tenantId/$environment/api/microsoft/automation/v2.0/companies($companyId)/extensionDeploymentStatus"
        try {
            $statusResponse = Invoke-RestMethod -Method Get -Uri $statusUrl -Headers @{
                "Authorization" = "Bearer $token"
            }
            Write-Host "Raw status response: $($statusResponse | ConvertTo-Json)"
            return $statusResponse.value
        }
        catch {
            Write-Host "Error getting status: $_"
            return $null
        }
    }

    # Check deployment status with timeout
    $maxAttempts = 30  # Maximum number of attempts (5 minutes with 10-second intervals)
    $attempt = 0
    $deploymentComplete = $false

    do {
        $attempt++
        $status = Get-DeploymentStatus

        if ($null -eq $status) {
            Write-Host "No status returned, retrying... (Attempt $attempt of $maxAttempts)"
            Start-Sleep -Seconds 10
            continue
        }

        # Check if status is an array and get the latest status
        if ($status -is [array]) {
            $latestStatus = $status | Sort-Object -Property StartTime -Descending | Select-Object -First 1
        } else {
            $latestStatus = $status
        }

        Write-Host "Current deployment status: $($latestStatus | ConvertTo-Json)"

        switch ($latestStatus.Status) {
            "InProgress" {
                Write-Host "Deployment in progress... (Attempt $attempt of $maxAttempts)"
                Start-Sleep -Seconds 10
            }
            "Completed" {
                Write-Host "Deployment completed successfully!"
                $deploymentComplete = $true
            }
            "Failed" {
                throw "Deployment failed. Error: $($latestStatus.errorMessage)"
            }
            "Unknown" {
                if ($attempt -ge $maxAttempts) {
                    throw "Deployment status check timed out after $maxAttempts attempts"
                }
                Write-Host "Status unknown, retrying... (Attempt $attempt of $maxAttempts)"
                Start-Sleep -Seconds 10
            }
            default {
                if ($attempt -ge $maxAttempts) {
                    throw "Deployment status check timed out after $maxAttempts attempts"
                }
                Write-Host "Status: $($latestStatus.Status), retrying... (Attempt $attempt of $maxAttempts)"
                Start-Sleep -Seconds 10
            }
        }
    } while (-not $deploymentComplete -and $attempt -lt $maxAttempts)

    if (-not $deploymentComplete) {
        throw "Deployment did not complete within the expected time frame"
    }

    Write-Host "Extension deployment completed and verified successfully!"
}
catch {
    Write-Error "Deployment failed: $_"
    exit 1
}

Leave a Reply