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 .app uploads, 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.
  • gh CLI authenticated to GHE: gh auth status (and export 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:

RangeRepo
50200–50249bc-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 (NuGet Microsoft.Dynamics.BusinessCentral.Development.Tools) on windows-latestnot AL-Go,
  • pulls platform symbols from the public BC artifact CDN (no committed binaries),
  • uploads <publisher>_<name>_<version>.app as 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 Symbols problem (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 .al files 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 = Installed

If 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 seeFix
Build fails on object 50100Compiler 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_InvalidCredentialsDeploying to an env where the deploy SP isn’t registered — only sandbox + DEV are set up
Error in query syntaxYou edited the upload call — schedule goes in the POST body, not the query string
Internal_EntityWithSameKeyExistsStale extensionUpload record — the supplied workflow reuses it; don’t recreate
Object won’t install / ID clashYour idRanges overlaps another installed extension — pick a free slice

Self-check

  1. Why do we compile with alc.exe instead of AL-Go? (Fewer unpinned third-party deps in production BC builds — a deliberate PSI stance.)
  2. Where does the schedule parameter go on the extension upload? (POST body, not the query string.)
  3. What does reusing bc-extension-deploy save you? (No new app registration, admin consent, or per-env BC registration — it’s already provisioned in sandbox + DEV.)
  4. Which environment must you never deploy to? (Production — held by direction.)
  5. What makes object IDs collide? (They must be globally unique across all installed extensions; overlapping idRanges between two installed apps blocks install.)