Deploy BC Extensions (AL / Business Central)

How PSI builds and publishes per-tenant AL extensions to Business Central SaaS environments — reproducibly, via CI, sandbox-first. Reference implementation: bc-metadata-extension (DEPLOYMENT.md).

Applies to all PSI BC extension repos (bc-metadata-extension, PSI-Shipping’s bc-extension, psi-workflow, psi-vendor-scorecard, psi-related-docs).


Build — standalone alc.exe, not AL-Go

Compile with the standalone .NET AL compiler shipped in the Microsoft.Dynamics.BusinessCentral.Development.Tools NuGet package, on a windows-latest runner. We deliberately avoid AL-Go for GitHub so production BC builds don’t depend on unpinned third-party actions.

  • Pull platform symbols in CI from the public BC artifact CDN (BcContainerHelper, e.g. v28 sandbox/US) — nothing is committed, no reachable sandbox required for a PR build.
  • Gotcha: extract the compiler NuGet outside the repo workspace. It ships template .al files (ALGo Template/HelloWorld.al, object 50100) that alc /project:. will otherwise sweep into the build and fail on.
& $alc /project:. /packagecachepath:.alpackages /out:"<publisher>_<name>_<version>.app"

Object ID ranges

Per-tenant extensions use the free 50000–99999 range (the compiler enforces app.json idRanges). Object IDs must be globally unique across all installed extensions in an environment, so partition the range across PSI’s BC repos to avoid “won’t install” collisions. AppSource apps (Cosmo, Insight Works, Cavallo, ExFlow, Avalara) use Microsoft-assigned ranges and won’t collide with 50000–99999.

Deploy — Automation API, sandbox-first

Publish via the BC Automation API with a client-credentials token. The extension upload is a 3-step flow (easy to get wrong):

  1. POST companies({id})/extensionUpload — body { "schedule": "Current version", "schemaSyncMode": "Add" }. schedule goes in the body, not the query string. schemaSyncMode: "Add" is additive (safe for additive-only extensions; use "Force Sync" only when schema must change).
  2. PATCH companies({id})/extensionUpload({id})/extensionContent — the .app bytes, If-Match: *.
  3. POST companies({id})/extensionUpload({id})/Microsoft.NAV.upload — install.
  4. Poll extensionDeploymentStatus until Completed.

extensionUpload is a singleton with no DELETE — a partial run leaves a staging record, so get-or-reuse it rather than POSTing a new one (else Internal_EntityWithSameKeyExists).

Always sandbox → DEV → Production, with Production guarded behind an explicit confirmation input.

⚠️ Per-environment registration is REQUIRED (the big gotcha)

In this tenant, app-only identities must be registered inside BC in every environment — Automation.ReadWrite.All / Graph admin consent alone does not grant BC access (you get Authentication_InvalidCredentials). For each environment:

  1. BC → Microsoft Entra Applications → + New → enter the app’s Client ID.
  2. State = Enabled → this provisions the app’s BC user. The permission-set grid is locked until enabled.
  3. Assign the required permission set(s).
  4. Grant Consent.

BC UI automation note: the Entra-app grid and dropdowns reject programmatically-typed text. Card fields (Client ID) accept typing; dropdowns need keyboard (open → ↑/↓ → Enter, which also fires confirm dialogs). Permission-set rows are easiest to assign via the Automation API (POST users({sid})/userPermissions {"roleId":"..."}) with an admin token, once the app is registered + enabled.

Roles by app purpose

App rolePermission set(s)Why
Deploy SP (publishes extensions)D365 AUTOMATION and EXTEN. MGT. - ADMINAutomation API access + extension install (Microsoft.NAV.upload fails “insufficient permissions” without EXTEN. MGT.)
Client/consumer app (calls custom APIs)The extension’s own permission set (e.g. PSI API Access)Access to the custom API pages

Deploy service principal

Create a dedicated SP (don’t reuse the runtime/client app). Grant Dynamics 365 Business Central → Automation.ReadWrite.All (Application) + admin consent; store its secret/config in repo secrets/variables. Then register it per-environment as above.

For bc-metadata-extension the deploy SP is bc-extension-deploy (dc2fdf7c-…) — see Azure Resource Map.

Troubleshooting

SymptomCause / fix
Authentication_InvalidCredentialsApp not registered + Enabled in that BC environment
You do not have sufficient permissions on installDeploy SP missing EXTEN. MGT. - ADMIN
Error in query syntaxschedule passed in query string instead of POST body
Internal_EntityWithSameKeyExistsStale extensionUpload record — reuse it, don’t recreate
Entity does not support deleteextensionUpload can’t be deleted — reuse it
AL compile fails on object 50100Compiler NuGet extracted inside the repo; extract to $RUNNER_TEMP
Permission set / API entity missing after installExtension not actually installed in that environment — verify via extensionDeploymentStatus / a real entity-set probe (the API group root returns 200 even when nothing publishes it)