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
}