Security change on December 1, 2026: TLS 1.2 will become the minimum standard for 365 business Print Agent. Details and system requirements
365 business development
Concepts

Runtime packages

Build-BcRuntimePackages produces signed Business Central runtime packages for on-premise distribution, one per supported platform version, and publishes them (Blob + NuGet feed) together with an "indirect" package that maps each platform version to its runtime package.

Licensed feature

Runtime package building is part of a licensed ALbuild tier and calls Assert-ALbuildLicensed on entry. See Licensing & tiers.


Why it must be optimised

A runtime package is generated server-side by a running Business Central service tier, and Microsoft only guarantees it on the exact platform version that produced it. So the product × platform version matrix is irreducible: you genuinely have to produce every cell.

What is reducible is the number of containers you start to do it. Measured on one real catalogue run:

Measured
Provisioning one container238–564 s, depending on the BC major
Value-adding work per product, once the container is upa near-constant ~80 s

In other words, the overwhelming majority of the wall-clock time is container provisioning, not building packages. Any optimisation that does not reduce container starts is noise.


Invert the loop: the runtime factory

The naive arrangement, and the one a per-product pipeline forces on you, iterates platform versions per product. That pays the provisioning cost once per product per version. Inverting it to iterate products per platform version pays it once per version, full stop.

For a 12-product catalogue over ~192 platform versions that is roughly 2,300 container starts against 192.

Inverting the loop also removes a failure mode instead of working around it. Several products depend on other products in the same catalogue. Per-product pipelines had to fetch those dependencies' runtime packages from blob storage and skip the platform version when they were not there yet. Built in one container in dependency order, the dependency is simply present: it was built moments earlier, in the same container.

This is what Get-BcRuntimeWorkSet and Invoke-BcRuntimeFactory do.

Planning: work sets, lanes and slices

Get-BcRuntimeWorkSet turns "which (product, app version, platform version) runtime packages are still missing?" into an ordered, sliceable plan. It is a pure function, with no feed, blob or artifact calls, so the planning policy is unit-testable and you can inspect an entire run before a single container starts.

  • Lanes. A product release invalidates the whole matrix at once, and a full rebuild takes hours however you schedule it. Customers pull the newest majors first, so -FastLaneMinMajor splits the plan: those versions are built and published first and the long tail follows behind, without holding the release up.
  • Slices. Each slice is the unit of work for one agent job. -MaxVersionsPerSlice bounds how long a single job runs, which matters because a job holds one of the organisation's few parallel slots for its whole lifetime, and because a job that dies then loses only its in-flight version.

Execution: a worker pool inside one job

Invoke-BcRuntimeFactory runs a slice through a pool of worker threads, each owning one container for one platform version.

The pool lives inside a job on purpose. Throughput is capped by the organisation's handful of parallel self-hosted jobs, not by the build host, which was measured with ~72 GB of RAM still free while four containers ran. Adding pipeline stages cannot get past that cap; adding workers inside a job does, and costs nothing extra.

Size the pool by disk, not by RAM

Each container costs roughly 2 GB of disk. On a well-specified build host it is the disk, not the memory, that caps how many workers you can run in parallel.

Checkpointing: never lose a finished version

One real run built 186 runtime packages and shipped 130 of them: the upload ran once, after the whole loop, so cancelling the run threw away ten hours of finished work.

-OnVersionComplete now runs after each platform version, so a cancelled or crashed run loses at most the version still in flight. The callback runs on the dispatcher thread deliberately: uploading to blob storage and pushing to a feed needs an Azure PowerShell context, and those context objects are not safe to share across runspaces. Workers produce files; the dispatcher ships them.

Per-app visibility

Azure DevOps renders a stage as a single icon, and giving every app its own stage would mean a container per (app, platform version), exactly the arrangement the factory exists to remove. The per-app result is therefore carried in artefacts a single job can publish:

OutputWhat you get
The logOne collapsible ##[group] per platform version, one line per app.
-JUnitPathOne test case per app per version. Feed it to PublishTestResults@2 for a green/red/skipped entry per app in the Tests tab.
-SummaryPathThe app × version matrix for ##vso[task.uploadsummary].

Worker output is captured per version and replayed by the dispatcher when that version finishes, so the log reads as ordered blocks instead of interleaved lines from parallel workers.


Still incremental

Independent of the factory, (app, version) combinations already present in Blob or the feed are skipped. See Get-BcRuntimePackageBuildPlan. Cached BC artifacts and the image cache mean a repeated version does not re-download the artifact either.


What is preserved

The engine preserves the full runtime-package behaviour:

  • ONPREM / BC{n} preprocessor-symbol injection and version-feature application.
  • Signing of both the app and the runtime package.
  • The runtime-{version} NuGet id scheme with platform/application version ranges pinned to the build.
  • The indirect package that maps each platform version to its runtime package.
  • Blob retention and downloads publishing.

Last modified on