Build & Ship a BC Extension (developer tutorial)
Hands-on, zero-to-deployed in ~30 minutes. You’ll scaffold a per-tenant AL extension, build it in CI, and publish it to the BC sandbox through the existing pipeline — no manual
.appuploads, no new Azure plumbing.This is the tutorial. For the reference runbook + full troubleshooting see Deploy BC Extensions. Reference implementation to copy from:
bc-metadata-extension.
The key idea: the deploy infrastructure already exists. A deploy service principal (bc-extension-deploy) is provisioned and registered in BC sandbox + DEV with install rights. So a new extension repo only needs the two workflows + a pointer to that SP — then git push builds it and one command deploys it.
Prerequisites
- VS Code + the AL Language extension (for local dev). Optional for CI-only, required to iterate locally.
ghCLI authenticated to GHE:gh auth status(andexport GH_HOST=progressivesurface.ghe.com).- Access to a BC sandbox (you have it if you can open
businesscentral.dynamics.com/<tenant>/sandbox). - An object ID sub-range that doesn’t collide with another PSI BC repo (see § Object IDs).
Step 1 — Scaffold the extension
Create a repo with an app.json and at least one object. Minimal app.json:
{
"id": "<new-guid>",
"name": "My BC Extension",
"publisher": "progressivesurface",
"version": "1.0.0.0",
"platform": "27.0.0.0",
"application": "27.0.0.0",
"runtime": "16.0",
"idRanges": [{ "from": 50300, "to": 50349 }],
"features": ["NoImplicitWith"]
}A trivial first object (HelloApi.Page.al):
page 50300 "My Hello API"
{
PageType = API;
APIPublisher = 'progressivesurface';
APIGroup = 'demo';
APIVersion = 'v1.0';
EntityName = 'hello';
EntitySetName = 'hellos';
SourceTable = "Company Information";
DelayedInsert = true;
layout { area(Content) { repeater(g) { field(name; Rec.Name) { } } } }
}Pick your object IDs
Per-tenant extensions use the free 50000–99999 range; the compiler enforces your app.json idRanges. IDs must be globally unique across all installed extensions in an environment, so claim an unused slice and record it.
The canonical range registry lives in the PSI-BC monorepo:
PSI-BC/OBJECT-IDS.md— it covers both the monorepo’s apps and every standalone BC repo in the estate, verified against eachapp.json. Check it (and update it in your PR) before claiming anything.
Highlights (see the registry for the full, current table):
| Range | Where |
|---|---|
| 50000–52999 | PSI-BC apps/PSI.Common — deliberately broad; do not claim slices inside it |
| 53000–53049 | PSI-BC apps/PSI.Dispatch.API |
| 53100–53149 | PSI-BC apps/PSI.RoutingManager (Route Manager replacement) |
| 50200–50249 | bc-metadata-extension |
| 78000–78200 | psi-bc-broadcast |
Step 2 — Add the build workflow (this is your compile)
Copy build.yml from bc-metadata-extension into .github/workflows/. It needs no changes if your app.json is at the repo root. It:
- compiles with the standalone
alc.exe(NuGetMicrosoft.Dynamics.BusinessCentral.Development.Tools) onwindows-latest— not AL-Go, - pulls platform symbols from the public BC artifact CDN (no committed binaries),
- uploads
<publisher>_<name>_<version>.appas an artifact.
Why CI is the source of truth, not local VS Code. The workflow fetches the exact platform symbols it needs from the BC artifact CDN every run — so it sidesteps the
AL: Download Symbolsproblem (the local download is notoriously flaky: wrong/blocked sandbox, stale symbol cache, auth prompts). You never fight symbol downloads in CI; a green build means it genuinely compiles against real BC v28 symbols. It also handles a non-obvious gotcha for you: it extracts the compiler NuGet to$RUNNER_TEMP, not the repo — otherwise the NuGet’s own template.alfiles get swept into your build and fail compilation.
Push a branch + open a PR → the build runs automatically. Green build = your extension compiles. This is your authoritative compile check from here on.
Optional: faster local iteration in VS Code
If you want a tight edit→see-it-in-sandbox loop while developing, VS Code + the AL extension can publish straight to your sandbox (AL: Publish / Ctrl+F5). Be aware you’ll need symbols locally first via AL: Download Symbols, which is the flaky step — if it won’t cooperate (blocked sandbox, auth, stale cache), don’t burn time on it: just push and let CI compile. CI is the gate that matters; local publish is only a convenience.
Step 3 — Add the deploy workflow + point it at the deploy SP
Copy deploy.yml too. Then set the repo to use the shared deploy SP (bc-extension-deploy, already registered in sandbox + DEV):
export GH_HOST=progressivesurface.ghe.com
R=ProgressiveSurface/<your-repo>
# Secret (value from Key Vault / the SP owner — never paste it in chat or commits):
gh secret set BC_DEPLOY_CLIENT_SECRET --repo $R # paste when prompted
# Non-sensitive config:
gh variable set BC_DEPLOY_CLIENT_ID --repo $R --body "dc2fdf7c-43d7-4e66-ac01-12be7cb2e05c"
gh variable set BC_TENANT_ID --repo $R --body "a83ae943-0a50-49cc-83c3-479b7a44b7fb"
gh variable set BC_SANDBOX_ENV --repo $R --body "sandbox"Reusing the shared deploy SP means no new Azure app registration, no new admin consent, and no new per-environment BC registration — that SP is already set up in sandbox + DEV. (If you’d rather have a dedicated SP, see Deploy BC Extensions § Deploy service principal.)
Step 4 — Ship it
# 1. Merge your PR (build is green) so the workflows are on main.
# 2. Deploy to sandbox:
gh workflow run deploy.yml --ref main -f environment=sandbox
gh run watch "$(gh run list --workflow deploy --limit 1 --json databaseId --jq '.[0].databaseId')"The deploy job compiles, then does the BC Automation API 3-step upload (create extensionUpload → PATCH the .app bytes → POST Microsoft.NAV.upload) and polls extensionDeploymentStatus until Completed. Promote to DEV the same way (-f environment=DEV). Production is held by direction — don’t deploy there.
Step 5 — Verify
# Confirm it installed (don't trust the API group root — it 200s even when nothing publishes it).
# Probe a real entity set, or check deployment status. Quick check via your sandbox in the browser:
# businesscentral.dynamics.com/<tenant>/sandbox → Extension Management → your extension = InstalledIf your extension exposes APIs for a client app to call, that client must (per environment) be registered in BC → Microsoft Entra Applications + Enabled and assigned your extension’s permission set. See Deploy BC Extensions § per-environment registration.
Common first-time failures
| You see | Fix |
|---|---|
Build fails on object 50100 | Compiler NuGet extracted in the repo — the supplied build.yml already extracts to $RUNNER_TEMP; don’t change that |
gh workflow run → “workflow not found” | deploy.yml must be on main first (workflow_dispatch only registers from the default branch) |
Authentication_InvalidCredentials | Deploying to an env where the deploy SP isn’t registered — only sandbox + DEV are set up |
Error in query syntax | You edited the upload call — schedule goes in the POST body, not the query string |
Internal_EntityWithSameKeyExists | Stale extensionUpload record — the supplied workflow reuses it; don’t recreate |
| Object won’t install / ID clash | Your idRanges overlaps another installed extension — pick a free slice |
Self-check
- Why do we compile with
alc.exeinstead of AL-Go? (Fewer unpinned third-party deps in production BC builds — a deliberate PSI stance.) - Where does the
scheduleparameter go on the extension upload? (POST body, not the query string.) - What does reusing
bc-extension-deploysave you? (No new app registration, admin consent, or per-env BC registration — it’s already provisioned in sandbox + DEV.) - Which environment must you never deploy to? (Production — held by direction.)
- What makes object IDs collide? (They must be globally unique across all installed extensions; overlapping
idRangesbetween two installed apps blocks install.)
Related
- Deploy BC Extensions — reference runbook + full troubleshooting
- bc-metadata-extension — reference implementation (copy its workflows)
- ERP Migration Tool — the first consumer of this pattern