A customer running a stable SDK, version 3.2.4, picks up the next release. Patch level only — 3.2.5. They expect bug fixes, possibly some performance work, nothing that requires their code to change.

They run their build, and it fails. A CMake target they were linking against has been renamed. A Kconfig symbol they had set in their prj.conf is no longer recognised. A device tree property they had defined in their board overlay is reported as unknown.

None of these were function signature changes. The C headers were untouched. From the SDK team’s perspective, the release was a patch release, and the changelog reflects that. From the customer’s perspective, the release was a breaking change, and they spent their afternoon recovering from it.

This is the gap between how SDK teams think about their public surface and how customers experience it. The C API is the part that gets versioned with discipline. Function signatures are tracked. Struct layouts are watched. Deprecations are announced two minor releases in advance with __attribute__((deprecated)) annotations. Meanwhile, the CMake target names, the Kconfig symbols, the device tree bindings, the Yocto layer names, and the component manifest identifiers are treated as internal implementation details that the team can rename whenever the rename feels right.

They are not internal. They are the names customers put into their build configurations, and the moment a customer types one of them into a file they maintain, that name is part of the SDK’s public contract. Renaming it is a breaking change.

This post is the case for treating the build system as part of the API, the categories of public surface that get overlooked, the semver mapping that should govern them, and the deprecation discipline that lets the SDK evolve those names without breaking customer builds.


The public surface most SDK teams underestimate

The C API gets the attention. Function names, parameter types, return values, struct layouts, header paths — these are the things teams instinctively treat as the contract. They are part of the contract. They are not all of it.

The build system surface is at least as wide, and on a modern embedded SDK using CMake plus Kconfig plus device tree, it is usually wider. Every name the customer references in their build files becomes a dependency, and every dependency is a thing the SDK team has implicitly committed to keep stable.

CMake target names. When a customer writes target_link_libraries(my_app PRIVATE sdk::hal_uart), the string sdk::hal_uart is part of the API. Renaming it to sdk::uart_hal because the new name reads better is a breaking change for every customer who linked against the old name. The customer’s CMakeLists.txt has to change for the build to work, and that is the definition of breaking.

Component or library names. In SDKs that use a meta-tool model or a component registry, the component name itself is an identifier customers reference in their workspace manifests or component.yml files. Renaming a component from bluetooth-host to ble-host — even with no API changes inside the component — breaks every consumer who pinned the old name.

Kconfig symbol names. When a customer has CONFIG_UART_DMA=y in their prj.conf, the symbol name is part of the API. Renaming it to CONFIG_UART_ASYNC_DMA because the new name is more accurate is a breaking change. The customer’s prj.conf has to change for the configuration to apply.

Kconfig symbol defaults and ranges. This one is subtler and bites harder. Changing the default value of CONFIG_STACK_SIZE from 1024 to 2048 in a minor release does not require any customer code change to compile — but it changes the runtime behavior of every customer who relied on the previous default. A customer whose RAM budget was tight enough that the new default exceeds their allocation now has a build that compiles and links cleanly and fails at runtime. From the customer’s point of view, that is worse than a build failure.

Device tree binding properties. In SDKs using a device-tree-driven driver model, every binding property is an interface customers reference from their board overlays. Renaming uart-speed to baud-rate because the new name is closer to the standard binding vocabulary is a breaking change for every overlay that referenced the old name.

Yocto layer names, recipe names, and machine names. For Linux-capable SoCs, the Yocto integration adds another wide surface. A customer’s bblayers.conf, local.conf, and machine configuration all reference SDK-provided names. Renaming a layer from meta-vendor-bsp to meta-vendor-platform is a breaking change for every customer build environment.

Manifest schema and registry identifiers. Component registries introduce a separate set of public names: the component identifier, the version constraint syntax, the lockfile schema. Each of these is a contract with the customer’s tooling.

A reasonable mental check is: if the customer has typed this string into a file they maintain, the SDK team cannot change it freely. The string is part of the API regardless of which file it lives in.


What semver looks like when you apply it consistently

The discipline that fixes this is conceptually simple: apply semantic versioning to the entire public surface, not just the C API. The whitepaper version of the table looks something like this when expanded to cover the build-system surface:

  • C API: new function added. MINOR — additive change, no migration required.
  • C API: function signature changed. MAJOR — every call site has to be updated.
  • C API: function removed. MAJOR — calls have to be migrated to a replacement.
  • Kconfig symbol renamed. MAJOR — every prj.conf referencing the old name breaks.
  • Kconfig symbol default changed. MINOR or MAJOR depending on impact — review whether the new default changes behavior in ways that require customer attention. A stack size increase that pushes a customer over their RAM budget is, in effect, a major-level change even though the configuration syntax is unchanged.
  • CMake target renamed. MAJOR — every CMakeLists.txt referencing the old name breaks.
  • Device tree binding property added (optional). MINOR — additive, backward compatible.
  • Device tree binding property renamed. MAJOR — every .dts referencing the old name breaks.
  • ABI: struct layout changed. MAJOR — every object file built against the old layout breaks.
  • Bug fix with behavior change. PATCH plus an explicit changelog note — even a fix that corrects a defective behavior is a behavior change for customers who had compensated for the defect.

The point of the table is not the categorisation in any individual row. The point is that the categorisation has to be applied uniformly across all public-surface elements, not selectively. An SDK that applies semver rigorously to its C API but treats Kconfig and CMake names as renameable at any time is shipping breaking changes in minor and patch releases without acknowledging that it is doing so.


The default-value question

The change category that creates the most friction with customers is also the most subtle: the case where a name is unchanged but its default value moves. This category has no analog in pure C API versioning, because C API changes are always visible in source — a function signature change, a struct addition, a removed field — but a default value change is invisible until runtime.

A few examples of how this manifests:

A CONFIG_STACK_SIZE default that increases from 1024 to 2048 bytes. Customers whose RAM budget had no headroom now run out of memory at link time or, worse, at runtime when the previously sufficient size is no longer applied because the customer never set the symbol explicitly.

A CONFIG_LOG_LEVEL default that decreases from INFO to WARN. Customer-facing logs that were available in the field are now silent, and the customer’s diagnostic workflow assumes log lines that no longer appear.

A CONFIG_USE_HW_FPU default that flips from disabled to enabled. Customers whose hardware doesn’t have an FPU, or whose calling convention assumed soft-float, now have ABI mismatches between application code and pre-built libraries.

The right discipline here is to treat default value changes as their own change category, not as patch-level fixes. Any default change that could plausibly affect customer behavior should be called out explicitly in the changelog under a “Defaults Changed” section, and any default change with significant behavioral impact — RAM usage, ABI, security posture — should be treated as a minor or major release event in its own right, even if the name is unchanged.


The deprecation discipline that lets you rename without breaking

The discipline applies in both directions. If renaming a CMake target or a Kconfig symbol is a breaking change, then the SDK can never improve its naming after the first release. That is not workable, because some names will turn out to be wrong, ambiguous, or inconsistent with later additions.

The mechanism that resolves this is the same mechanism the C API uses: deprecation. The SDK can rename a CMake target by introducing the new name, keeping the old name as an alias that also works, marking the old name as deprecated in the changelog and in any documentation, and removing the old name only in the next major release. The deprecation window is a minimum of two minor releases or six months, whichever is longer — the same window applied to deprecated C API symbols.

For CMake, the alias is a one-line add_library(sdk::old_name ALIAS sdk::new_name). For Kconfig, it is a config OLD_NAME symbol that selects the new name with a deprecation comment. For device tree bindings, it is a binding that accepts both the old property name and the new one, with a build warning when the old one is used. None of these is technically difficult; what they require is the discipline to do them every time, not just when someone remembers.

The deprecation message also matters. A [DEPRECATED] sdk::uart_hal is deprecated. Use sdk::hal_uart instead. Will be removed in SDK v4.0. printed at configure time is the difference between a customer noticing the rename in advance and a customer being surprised by the removal three releases later. Silent deprecation does not work, because customers do not read changelogs as carefully as SDK teams hope.


Mechanical enforcement beats good intentions

The reason most SDKs ship build-system breaking changes accidentally is that the discipline depends on individual engineers remembering to think about backward compatibility every time they rename something — and the team naming things is usually not the team that feels the pain when names break.

The fix is mechanical enforcement. Three checks, ideally automated as part of the release process, catch most accidental breakage:

Public-symbol diff at release time. A simple tool that lists every CMake target, every Kconfig symbol, every device tree binding property, and every component name in the current release, and diffs that list against the previous release. Any removal or rename surfaces in the release process and requires explicit justification — usually a major-version increment or an alias plus deprecation.

Customer-build smoke tests. A small set of representative customer-style applications, built against every release candidate. These applications reference targets, set Kconfig symbols, and define device tree overlays the way customers do. A break in any of them surfaces a public-surface change before the release ships.

Naming review at PR time. Any pull request that adds a new public name — a new CMake target, a new Kconfig symbol, a new device tree binding — gets reviewed for the name itself, not just the implementation. Public names are forever, and the cost of getting one wrong on day one is paid for every release after that.

These checks are not exotic, but they have to be present. Without them, the SDK’s public surface evolves through a series of well-intentioned individual decisions that collectively constitute a breakage pattern, and the cost lands on customers who did nothing wrong.


The build system is the first thing customers touch

The build configuration is the first surface a customer interacts with — before the C API, before the documentation, before the runtime behavior. A customer who downloads the SDK, edits a CMakeLists.txt, and gets a clean build has had a successful first interaction. A customer who downloads the next minor release, runs the same build, and gets an error about an unknown target name has had a breaking interaction, regardless of whether the C headers are bit-identical.

The implication is that the build system deserves the same versioning discipline, the same deprecation policy, the same release-gate testing, and the same changelog visibility as the C API. It is not infrastructure. It is interface.

The cost of treating it that way is mostly process — release-time diff tooling, naming review at PR time, deprecation aliases for renames — and it pays back as customer trust in the version number. A customer who knows that a patch release is genuinely safe to apply will apply patch releases promptly, which is exactly what an SDK team wants for security fix uptake. A customer who has been burned by a “patch” release that broke their build will hold every future release for an extra cycle of testing, which is exactly what an SDK team does not want.

The version number on the SDK is a promise. Including the build system in that promise is what makes the promise true.


A patch release that breaks customer builds is a trust problem, not a versioning problem.

needCode designs embedded SDK build systems with the same versioning discipline as the C API — CMake targets, Kconfig symbols, and device tree bindings treated as public surface from day one. If your SDK team is tired of explaining why patch releases aren’t safe to apply, let’s talk.

Book a free discovery call or get in touch


Further reading