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. Current allocations:
| Range | Repo |
|---|---|
| 50200–50249 | bc-metadata-extension |
| 50300–50349 | (example — yours) |
Check the other BC repos’ app.json before claiming a range.
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