A declarative CMake DSL for turning other people’s CMake and Meson projects — plus libraries built directly in your parent CMake project — into first-class nodes in your graph.
You register components and edges in any order. BuildMaster materializes
stage targets, nested-build IMPORTED libraries, and link lines once —
at the end of the parent CMAKE_SOURCE_DIR — into a single install prefix.
Host components are real CMake targets from the moment you register them;
their BuildMaster graph links are wired after the full graph is ready.
This is not a wrapper around ExternalProject_Add. It is not FetchContent
with extra macros. It is a small language for graphs of third-party builds.
Want to say “I need to link my app (or my lib) against something BuildMaster already built — freetype, harfbuzz, libpng, the library you wrote last Tuesday — and not keep a spreadsheet of everything that library quietly pulls in”? That is the sentence this file is for.
One well-behaved CMake library? FetchContent is enough.
Twelve upstreams — some CMake, some Meson, some that install zsd.lib when
you asked for z.lib, some that must configure after another prefix
exists, some that only work under clang-cl, and a static plugin pack the
linker will drop unless you wrap it in --whole-archive — and you already
have a private orchestration layer. Usually it is add_custom_command,
hardcoded paths, and “remember to declare zlib before libpng”. Then you
link libpng and Windows reminds you about shlwapi because somebody
three levels down needed it and nobody wrote it down.
Those weeks do not make you a worse programmer. They make you the person this file is for.
BuildMaster is that layer, written once:
| You stop writing… | You get… |
|---|---|
| “Declare A before B or configure explodes” | Order-independent registration |
ExternalProject that only configures at build time |
Eager configure when the graph allows it |
| A wait edge and a link edge for the same pair | buildmaster_link already waits |
| “Link png, and also freetype, and also harfbuzz, and also whatever they grew last week” | links/ + buildmaster_link(app png) |
A second add_library(Foo::Bar ALIAS …) in every consumer |
ALIAS=Foo::Bar on the component |
Hand-rolled Meson setup that misses .pc files |
Same prefix, PKG_CONFIG_PATH, compilers, cache launchers |
POST_BUILD rename scripts per MSVC flavor |
RENAME (default on) |
--whole-archive soup in the parent |
WHOLE on a component or a meta |
LNK2005 / duplicate .res after /WHOLEARCHIVE |
STRIPRES on static MSVC / clang-cl archives (default on) |
shlwapi on every consumer because a static .lib does not record it |
LINK={…} on the producer (or the meta) |
undefined pq after linking Database.a — and the urge to REPACK the world |
links/<id>_static.txt (see Static archives) |
| A static library whose system or BuildMaster links vanish at the final executable | BACKEND=host: keep a real CMake target and let its public link requirements travel |
/FORCE:MULTIPLE on the parent because one leaf needed it |
LINKFLAGS={…} on that leaf (not inherited) |
Parent -flto / /GL leaking into a leaf that cannot probe under LTO |
IPO=off on that leaf (or IPO=fat when you still need real objects) |
Hand-written .pc so the next Meson node finds this prefix |
PC={…} on the leaf |
Six hours of “it works on my Linux box”, then 02:00 and a Windows CI that never heard of pkg-config |
REQUIRE_TOOL=pkgconfig |
A submodule whose meson.build / CMakeLists.txt lives one folder down |
SOURCE=libfoo (same isolation as GIT ROOT=) |
Dual markers and a private _bm_backend_*_create |
BACKEND=cmake / BACKEND=meson |
cmake_language(DEFER) so a summary line appears after the graph |
Hooks |
Waiting on a slow tarball every rm -rf build |
BUILDMASTER_DOWNLOADSDIR outside the build tree |
Four public git helpers plus an include() |
GIT={…} on the component |
| A download target plus a prerequisite edge | FILES={…} on the component |
Manual INDENT= so related leaves line up in the log |
buildmaster_group |
BUILDONLY= because “maybe I can turn it off later” |
NOINSTALL (a flag, not a switch) |
The cost is a short public API. The payoff is a parent tree that looks like a product, not a build blog.
- Quick start
- Ten commands
- How a component works
- Dependencies and links
- Aliases (
ALIAS) - Exported links (
links/) - Static archives and the sidecar
- Raw system libraries (
LINK) - Raw linker flags (
LINKFLAGS) - Meta components
- Groups
- Component options
- Source tree (
SOURCE) - Backend (
BACKEND) - No-install components and repack
- Header-only components
- Executables
- Files (
FILES) - Git (
GIT) - Helper pkg-config files (
PC) - Extra tools (
REQUIRE_TOOL) - Whole-archive linking (
WHOLE) - Stripping
.resmembers (STRIPRES) - Interprocedural optimization (
IPO) - Subcomponent specs
- Per-component toolchains
- Hooks
- Orphan warnings
- Logging
- Verbosity of tool output
- Fail-fast
- Compiler cache
- Recursive usage
- Platform notes
- Comparison
- Self-tests
- License
- Supporting the project
add_subdirectory(path/to/buildmaster)
buildmaster_message(STATUS "Setting up My Library" 1)
buildmaster_component(
mylib
"My Library"
"${CMAKE_SOURCE_DIR}/thirdparty/mylib/src"
"ENABLE_FOO=ON;WITH_TESTS=OFF"
shared
mylib
"ALIAS=My::Lib"
)
target_link_libraries(MyApp PRIVATE mylib)
# or: target_link_libraries(MyApp PRIVATE My::Lib)Same factory, a tool that uses that library. The nested tree must still
target_link_libraries / link_with the archive — BM waits and puts
-I/-L on the nested compile; it does not invent a CMake target
inside someone else’s CMakeLists.txt:
buildmaster_component(
mytool
"My Tool"
"${CMAKE_SOURCE_DIR}/thirdparty/mytool/src"
""
executable
mytool
)
buildmaster_link(mytool mylib)No build directory. No out-variable. No generated fragment to include().
For nested CMake/Meson backends, the backend is inferred from srcdir
(CMakeLists.txt vs meson.build), after SOURCE= if you wrote one.
Those stage targets and the INTERFACE stub named mylib exist when
registration returns; produced paths are filled in at the end of
CMAKE_SOURCE_DIR. BACKEND=host instead creates a real target immediately.
The fourth argument is a CMake list of KEY=value (a single string
is a one-element list). For nested CMake/Meson components it is
backend-agnostic on purpose: write names a human can read, not generator
flags. A host component usually leaves this argument empty and uses normal
target_* commands on its real target.
Six keys are idioms and are rewritten for the backend:
| Key | What happens |
|---|---|
CFLAGS / CXXFLAGS / CPPFLAGS / LDFLAGS |
Appended to the parent job flags. They do not replace CMAKE_* or Meson *_args. |
INCLUDES |
Directory (relative to srcdir unless absolute) → compile -I. |
DEFINITIONS |
FOO or FOO=1 → compiler -D. |
Everything else is forwarded as -DKEY=value to the nested CMake
configure and to the nested Meson setup (Meson also uses -D,
not -d). A leading -D, -d or /D on the key is stripped, so
-DENABLE_FOO=ON and ENABLE_FOO=ON are the same pair. Prefer the
form without the prefix.
These options are private to that nested configure / compile. They are
not INTERFACE on <id> and they are not ENV{CFLAGS}. A headers
component with no backend (none) ignores the list.
Optional policy string (one trailing argument):
buildmaster_component(
mylib
"My Library"
"${CMAKE_SOURCE_DIR}/thirdparty/mylib/src"
"ENABLE_FOO=ON"
static
mylib
"TOOLCHAIN=clang-cl;WHOLE;ALIAS=My::Lib;LINK={shlwapi;ws2_32};PC={VERSION=1.2.3;NAME=mylib};REQUIRE_TOOL=pkgconfig"
)A leaf that must not inherit the parent’s LTO:
buildmaster_component(
tinyprobe
"Tiny probe lib"
"${CMAKE_SOURCE_DIR}/thirdparty/tinyprobe/src"
""
static
tinyprobe
"IPO=off"
)On a static MSVC / clang-cl archive, STRIPRES and RENAME are already
on. Write them only when you want them off.
add_subdirectory is enough. Do not include(helpers.cmake) from a
consumer.
BuildMaster can live anywhere: use its actual relative or absolute path in
add_subdirectory. The only ordering rule is the ordinary CMake one: add
BuildMaster before calling any buildmaster_* command. No sibling-folder
layout is required.
Ten commands. If you need an eleventh, the optstr is lying or the graph is.
| Command | Role |
|---|---|
buildmaster_component(id title srcdir-or-sources options mode produced [optstr]) |
Factory. Backend from srcdir, or an in-process target with BACKEND=host. No builddir |
buildmaster_depend(source dest) |
Order-only edge |
buildmaster_link(source dest [dest…]) |
Link on the component target and a depend edge when dest is a graph node. Aliases resolve to ids |
buildmaster_meta(id title [, optstr]) |
INTERFACE collection. REPACK publishes one merged static archive |
buildmaster_meta_add(meta member…) |
Membership (allowed before buildmaster_meta) |
buildmaster_group(id [title]) |
Outline banner. No target, no edge |
buildmaster_group_add(group member…) |
Membership (group / component / meta). Allowed before buildmaster_group |
buildmaster_hook_component(id fn alias [CAPTURE …]) |
Run fn after that id materializes |
buildmaster_hook_graph(fn alias [CAPTURE …]) |
Run fn after the whole graph materializes |
buildmaster_message(level text [, indent]) |
Log. Module is always USER |
Everything else is _bm_* and is not a supported API.
Ids become target and script names — keep them filesystem-friendly. Titles may contain spaces; they only appear in status lines.
While the parent is still configuring, you declare. You do not
include() generated fragments. You do not call a public finalize.
Materialization runs once via an internal cmake_language(DEFER) at the
end of CMAKE_SOURCE_DIR.
Nested CMake/Meson components use an INTERFACE target and run the three
stages in order:
<id> (INTERFACE) → <id>_configure → <id>_build → <id>_install
BACKEND=host is different: <id> is already a real STATIC or SHARED
target. Its empty _configure, _build, and _install targets are ordering
anchors; _build depends on the library and _install depends on _build.
You can use target_include_directories, target_compile_options, properties,
and other ordinary CMake commands immediately after buildmaster_component.
| Target | Role |
|---|---|
<id> |
Nested backend: INTERFACE, depending on <id>_install. Host: real STATIC / SHARED library, available immediately. |
<id>_configure |
Nested CMake or Meson setup; empty ordering anchor for host |
<id>_build |
Nested compile; for host, depends on the real parent-project library target |
<id>_install |
Nested backend publishes into the shared prefix and runs oficios. Host: empty anchor depending on _build; use normal parent CMake install() rules to publish it |
| produced libs | Nested backend: STATIC / SHARED IMPORTED files under the prefix (or build dir). Host: real parent-project library target |
| produced exe | File under BINDIR (or the build dir). No IMPORTED executable on <id> |
| Nested configure | When |
|---|---|
| Eager | The component is not the source of any recorded wait edge |
| Deferred | It must wait on another node — configure runs at build time under <id>_configure |
For nested backends, the INTERFACE stub exists as soon as you call
buildmaster_component. For host, the real library exists at that point.
ALIAS= is applied to whichever target the backend creates. You may still
write add_library(Vendor::Foo ALIAS foo) yourself; a clash with a different
target is FATAL.
BuildMaster assigns ${CMAKE_CURRENT_BINARY_DIR}/bm/<id> and creates it.
There is no public builddir argument and no ensure_build_dir.
ninja comes up with the tools tree. The archiver is the active
TOOLCHAIN= profile (CMAKE_AR, Meson [binaries] ar / ld), not a
tool. cmake, meson, git, and file start the first time a
component actually needs them. Extra tools (pkgconfig, …) start only
from REQUIRE_TOOL.
Order-only edge. At materialize time dest resolves as the first match:
- Registered component id →
<id>_install(publishing andNOINSTALL: oficios on the produced stem) - Registered meta id →
<id>_install - Name matching
*_install/*_configure/*_build - Existing CMake target
Otherwise: FATAL — unless the same pair is also a buildmaster_link
to a library spec or an on-disk archive. That dest is link-only.
A second explicit call with the same (source, dest) is WARNING
and a no-op. Unresolvable dest at finalize stays FATAL.
A group is not a graph node. buildmaster_depend(foo grp-audio) is FATAL.
Aliases passed as source or dest resolve to the id first.
Records a link on the component target. For nested backends that is the
component INTERFACE; for host it is the real CMake library target. Several
dests on one call are the same contract applied once each. A dest repeated in
the same call is WARNING + skip.
Edges are recorded now and resolved after all components materialize. That is what lets a host target link a component declared later without a stub link being attempted too early.
dest may be another component, a meta, an alias of either, an existing
CMake target, an archive that already exists on disk, or a library spec
(<name> / <subdir>/<name>) under the BM prefix.
Link already waits. buildmaster_link(A B) records the same
order-only edge as buildmaster_depend(A B) when B is a graph node,
even if B is registered later.
buildmaster_link to a NOINSTALL dest is FATAL. Wait with
buildmaster_depend, or publish the archives through a REPACK meta
or a static REPACK component.
"ALIAS=Vendor::Foo"
"ALIAS={Vendor::Foo;Vendor::FooLegacy}"After the INTERFACE stub exists, BuildMaster does
add_library(<alias> ALIAS <id>) for each name. Valid on a component
and on a meta.
If <alias> already maps to a different target: FATAL with a BM
sentence, not the raw CMake one. Mapping again to the same id is a
no-op.
buildmaster_link / buildmaster_depend accept the alias. The edge is
stored under the real id.
This is the “I only wanted png” clause.
Every materialized component and every created meta writes one file:
${BUILDMASTER_LINKS_DIR}/<sanitized-id>.cmake
and always a sidecar next to it:
${BUILDMASTER_LINKS_DIR}/<sanitized-id>_static.txt
BUILDMASTER_LINKS_DIR sits next to scripts/ under the trunk
bindir. It is propagated and dumped into the toolchain file, so a
nested cmake — another process, another repo — still sees the same
directory. There is one BuildMaster. There is one links/.
The .cmake is generated from a template (same idea as the git / cmake
stage scripts). It carries:
- the id and its
ALIAS=names - the prefix include dir (or the
NOINSTALLbuild dir) - linker names plus
-L(not raw.a/.libpaths: those have no Ninja rule in a parent that never registered the leaf) - BM dests recorded with
buildmaster_link
The _static.txt is data, not a script. Ingest still globs
*.cmake only. Flatten file(READ)s the sidecar. Shared / headers /
executable write set(_BM_STATIC_LINK ""). Static writes the union of
that id’s LINK= tokens and its buildmaster_link dest ids.
A later tree include()s those .cmake files and can write:
buildmaster_component(
myapp_plugin
"My plugin"
"${CMAKE_SOURCE_DIR}/plugin"
""
static
myplugin
)
buildmaster_link(myapp_plugin freetype)If freetype, harfbuzz and libpng were all declared with
buildmaster_component (or a meta) in the tree that built them, the
plugin does not list harfbuzz and libpng. The links/freetype.cmake
file already knows. If those ids were static, the sidecar is why
the final exe still sees libpng.a and m without you naming them.
Rules that keep this honest:
- Only BM nodes go into
links/*.cmake. A rawtarget_link_librariesinside a nestedCMakeLists.txtis invisible. If the nested project is itself a BM graph, usebuildmaster_linkthere too. - Same id declared again is first-wins. Configure prints
Skipping configure of <title> — already registered as…oralready built by…and does not compile it twice. Reuse is valid only if afterinclude()the TARGET<id>exists. Version comparison is a 2.1 problem. buildmaster_cleandeleteslinks/with the rest of the bindir.- System libs (
shlwapi,m,-framework CoreFoundation) stay onLINK=. They are not BM ids. Bundledpqis a BM id:buildmaster_link(Database PostgreSQL), never-lpqby hand. - An effective nested reconfigure (stamp miss and a real
cmake -S) deletes<id>.cmakeand<id>_static.txtbefore rewrite. A stamp or reuse skip leaves both files.
Use BM for the whole chain and the parent stays one line. Mix a hand-rolled leaf in the middle and you are back to writing dests yourself — that is fair.
A .so / .dylib / .dll absorbs what it linked PRIVATE.
The test that links libStormByte-Database.so does not need -lpq.
A .a / .lib absorbs nothing. The same test that links
libStormByte-Database.a dies with undefined PQconnectdb unless
pq is on that line. That is not a BM bug. That is how archives
work. The usual panic is REPACK — glue postgres into Database so
the consumer only sees one file. Do not. REPACK is for
NOINSTALL members you never wanted on the prefix. Postgres is a
real component. It stays a real .a.
That does not mean REPACK is the only way to keep static dependencies
attached. If the library is yours to build in the parent project,
BACKEND=host gives it a real CMake target: LINK= system libraries and
buildmaster_link component targets become public link requirements for a
static library, so consumers inherit them through CMake. Use REPACK when
you deliberately want one physical archive made from NOINSTALL members,
not just to carry link requirements to the final executable.
Nested build backends emit links/<id>_static.txt:
# Auto-generated by BuildMaster — do not edit
set(_BM_STATIC_LINK "PostgreSQL;m")
Empty list when the id is shared (or has nothing PRIVATE). Flatten, when it walks a dest, reads that file if it exists:
- Token that has
links/<token>.cmake→ walk that dest (-Lfrom itsLIBDIR,-l/.libfrom itsLIBNAMES). Bundledpqwins over the system one because the prefix-Lis first. - Token with no dest file (
m,ws2_32,-framework CoreFoundation) → emit as-is. Darwin frameworks stay one token, not-lCoreFoundation.
The consumer still writes one line:
target_link_libraries(tests PRIVATE StormByte-Database)Shared Database: sidecar empty, tests see the DSO. Static Database:
sidecar lists PostgreSQL (and m if you put it on LINK=), tests
get -L<prefix> -lpq without naming postgres and without REPACK.
Do not include() the .txt. Do not put ADD or
target_link_libraries in it.
Host targets do not use this sidecar. Their real CMake link interface carries public system and component requirements from static libraries to consumers.
LINK= / LINK={…} are raw linker names (shlwapi, ws2_32, m,
-framework CoreFoundation). For nested backends, they go on the id’s
INTERFACE; static ids also record them in _static.txt so a later flatten
still sees them when the IMPORTED .a forgot. For BACKEND=host, they are
applied directly to the real target: PUBLIC for static and PRIVATE for
shared. That is standard CMake usage-requirement propagation, not a graph edge.
They are not graph nodes. A BM component belongs in
buildmaster_link, not in LINK=. Bundled vs system is the dest
file: buildmaster_link(Database PostgreSQL) writes the id
PostgreSQL. Flatten uses that id’s -L. -lpq in LINK= is the
system library and you will lose the fight on a machine that has both.
LINKFLAGS= / LINKFLAGS={…} are raw linker flags
(/FORCE:MULTIPLE, -Wl,-Bsymbolic) for the nested cmake/meson
link of that component only.
They are folded into that id’s OPTIONS at finalize. They are not
target_link_options on the INTERFACE. A consumer of this id does
not inherit them.
Platform groups: WINDOWS, LINUX, MAC, UNIX (UNIX = Linux +
macOS). A group that does not apply is skipped at INFO. An unknown
platform key is FATAL.
Meta and headers: WARNING + ignore. Put the flags on the member that
actually links. executable keeps them — that is a nested link.
buildmaster_meta(plugins "plugin pack" "ALIAS=Vendor::Plugins")
buildmaster_meta_add(plugins opus vorbis)
buildmaster_link(engine plugins)buildmaster_meta_add may run before buildmaster_meta.
A meta is an INTERFACE bucket. No srcdir, no nested configure.
GIT= / FILES= / SOURCE= / BACKEND= on a meta are FATAL.
LINKFLAGS= on a meta is WARNING + ignore. TOOLCHAIN= copies onto
members that did not pin one. IPO= does the same: members that omit
IPO= inherit the meta; a member that wrote IPO= keeps its own
value (no FATAL if two metas disagree). REQUIRE_TOOL= on a meta is
accepted. NOINSTALL on a meta is prevalent: finalize stamps every
member. ALIAS= on a meta is the same contract as on a component.
A group cannot be a meta member.
Outline only. No INTERFACE, no stages, no edges.
buildmaster_group(grp-audio "Audio")
buildmaster_group_add(grp-audio opus speex)buildmaster_group_add may run before buildmaster_group.
Members may be components, metas, or other groups. FATAL only at
the end, and only if that group was never created. Cycles and id
clashes are FATAL.
Groups are not consumption. Linking still goes through
buildmaster_link on the real ids.
One optional trailing argument:
KEY=value;KEY2=value with spaces;PC={VERSION=1.2.3;NAME=foo}
- First
=in each pair splits key from value. ;inside{…}is not a pair break.- A trailing
;is allowed. - Keys are case-insensitive, stored uppercase.
- Bare flag (
RENAME,WHOLE,NOINSTALL,STRIPRES,REPACK,REQUIRE_TOOL,IPO) is accepted by the splitter. BUILDONLYis accepted by the splitter only so the parser can FATAL (use NOINSTALL).- Unknown keys: WARNING, ignored.
- Extra positionals: FATAL.
| Key | Default | Notes |
|---|---|---|
TOOLCHAIN |
parent | Profile name (gcc, clang, clang-cl, msvc) |
IPO / IPO= / IPO=on|off|fat |
inherit | Per-id LTO. Omitted follows the parent. Invalid value is FATAL |
RENAME |
ON | Normalize variant archive / binary names after install |
WHOLE |
OFF | Whole-archive the produced statics |
STRIPRES |
ON | Strip *.res from static MSVC / clang-cl archives |
NOINSTALL |
OFF | Build without publishing to the shared prefix. Flag, not a switch |
BACKEND |
detect | cmake or meson when both markers exist; host builds an in-process target from the third-argument source list |
SOURCE |
(srcdir) | Subtree under the positional srcdir. Applied before detect |
ALIAS= / ALIAS={…} |
empty | add_library(alias ALIAS id) after target creation |
REPACK |
OFF | Meta, or a static component: merge NOINSTALL static dests into the prefix archive. Not a substitute for _static.txt or host target link propagation |
PC={…} |
off | Write a helper .pc after install. Does not demand pkg-config. FATAL on executable |
LINK= / LINK={…} |
empty | Raw system linker names. Nested static ids also copy them into _static.txt; host applies them to the real target |
LINKFLAGS= / LINKFLAGS={…} |
empty | Raw flags for the nested link only |
GIT={…} |
empty | Fetch / switch / reset / patch. ROOT= uses the same isolation as SOURCE= |
FILES={…} |
empty | Download / unpack / optional inner SOURCE tree (not the optstr) |
REQUIRE_TOOL= / REQUIRE_TOOL={…} |
empty | Demand extra tool (pkgconfig, …) |
INDENT= |
ignored | WARNING. Use buildmaster_group |
REQUIRE_TOOL / REQUIRE_TOOL= / REQUIRE_TOOL={} is WARNING and
ignored. A name that is not a known extra is FATAL.
NOINSTALL= and NOINSTALL=ON enable with WARNING
(write NOINSTALL, not NOINSTALL=…). NOINSTALL=OFF is FATAL
(omit the key to install).
Bare IPO and IPO= mean thin LTO on. See
Interprocedural optimization (IPO).
Tired of a git submodule whose meson.build or CMakeLists.txt sits
in a subfolder and not at the tree root? That is what SOURCE= is for.
buildmaster_component(
foo
"Foo"
"${CMAKE_SOURCE_DIR}/thirdparty/foo/src"
""
static
foo
"SOURCE=libfoo"
)SOURCE= is always under the positional srcdir. A leading /
is still a child of that srcdir (/libfoo → srcdir/libfoo), not an
absolute path on the host. Escape above the component srcdir — or
above the host CMAKE_SOURCE_DIR — is FATAL before any existence
probe. After that check, a missing directory is FATAL.
Detect runs on the resolved tree. Dual markers there are still FATAL
unless you also write BACKEND=.
This is not FILES={…;SOURCE}. FILES SOURCE replaces the srcdir
after an unpack. The optstr SOURCE= selects a child of the srcdir
you already passed.
When srcdir (after SOURCE=) contains both CMakeLists.txt and
meson.build, detect is FATAL. Say which generator you meant:
"BACKEND=meson"Allowed names live in BUILDMASTER_FACTORY_BACKENDS (cmake, meson,
host in this release). Empty or unknown: FATAL. host is a BuildMaster
mode, not a third-party build generator. There is no BACKEND=none
— that is headers without a backend, or NOINSTALL when you truly
have no generator.
Use this when you own the library sources and want an ordinary target in the
parent CMake project instead of a nested configure. The third argument is a
list of source files, not a directory. Only static and shared modes are
supported; the target name is the component id and is ready for raw CMake
commands as soon as registration returns.
file(GLOB_RECURSE mylib_sources CONFIGURE_DEPENDS
"${CMAKE_CURRENT_LIST_DIR}/src/*.cxx")
set(_host_options "BACKEND=host;ALIAS=Vendor::MyLib")
if(WIN32)
list(APPEND _host_options "LINK={advapi32;iphlpapi;ws2_32}")
elseif(APPLE)
list(APPEND _host_options "LINK={-framework CoreFoundation;-framework IOKit}")
endif()
buildmaster_component(
mylib
"My Library"
"${mylib_sources}"
""
static
mylib
"${_host_options}"
)
target_include_directories(mylib SYSTEM BEFORE PUBLIC
"${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_definitions(mylib PRIVATE MYLIB_BUILDING)
buildmaster_link(mylib base)
install(TARGETS mylib ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}")The registration creates the real library and its stage anchors, but does
not run a nested install. Add normal CMake install() rules when the host
library should be published by the parent project's install step.
buildmaster_link records graph edges at declaration; BuildMaster applies
them after every component has materialized, so declaration order does not
constrain host dependencies. On static host targets, both component links
and LINK= requirements propagate to the final consumer.
NOINSTALL keeps artifacts under the component build dir. They never
land in the shared prefix. The <id>_install target still exists: it
does not run cmake --install / meson install, but it does run
oficios (RENAME included) so the BUILDDIR stem is the produced name
(foo.lib, not foo-static.lib). REPACK and buildmaster_depend
wait on that _install, not on _build.
buildmaster_meta(id title "REPACK") plus buildmaster_meta_add
merges every produced static archive of the member leaves into one
prefix archive named after the meta id. Shared members stay INTERFACE
(WARNING). NOINSTALL + shared as a REPACK member is FATAL.
REPACK on a static buildmaster_component merges first-level
depend/link dests that are NOINSTALL static into that component’s
already-installed prefix archive. It is not how you propagate
pq / CoreFoundation / m to a test that links Database.a. That
is the sidecar. Headers / executable + REPACK on the same id is
FATAL. An executable dest of a REPACK publisher is skipped (INFO),
not a member. Shared + REPACK on a component is WARNING + skip.
Zero static members is FATAL. REPACK + NOINSTALL on the same id
is FATAL.
BUILDONLY is gone. Write NOINSTALL.
Mode headers. No produced spec.
- Backend + not
NOINSTALL: headers install into the shared prefix. - No backend (
none) orNOINSTALLheaders: private island. Direct consumers get a quoted-Ion that id’s nested compile only.
Mode executable. Produced spec is a binary stem, not libfoo.a.
The file lands under BUILDMASTER_INSTALL_BINDIR (bin/<stem> on Unix,
bin/<stem>.exe on Windows). NOINSTALL keeps it under the component
BUILDDIR.
<id> is still an INTERFACE stub. Do not target_link_libraries(host mytool)
expecting symbols from the binary. Run the file after <id>_install.
The nested project is what actually links libraries (find_library /
link_with); buildmaster_link(mytool mylib) is the wait edge and the
parent INTERFACE, not a target inside the upstream CMakeLists.txt.
A nested CMake or Meson executable that links several static archives
can list the provider before the consumer. GNU ld.bfd walks an
archive once. Under LTO that shows up as an undefined symbol that
nm can still see in the .a. On Linux gcc/clang, BuildMaster wraps
<LINK_LIBRARIES> with -Wl,--start-group / --end-group on both
the nested leaf and the parent CMAKE_<LANG>_LINK_EXECUTABLE. Darwin
ld64 and MSVC / clang-cl already rescan; they do not get those
flags. SHARED and MODULE recipes stay as CMake wrote them.
RENAME (default ON) normalizes variant binary names onto the produced
stem. WHOLE / STRIPRES / enabled PC={…} do not apply (PC ENABLED
is FATAL). REPACK on the executable itself is FATAL.
"FILES={URL=https://example.com/foo.tar.xz;NAME=foo.tar.xz;SHA256=…;UNPACK;SOURCE}"Cached under BUILDMASTER_DOWNLOADSDIR. Meta + any FILES key is FATAL.
GIT={…} + FILES SOURCE is FATAL (two owners of the same tree).
"GIT={FETCH;SWITCH=release/1.2;RESET;PATCH=patches/foo.patch;ROOT=src}"Order is fixed: FETCH → SWITCH → RESET → PATCH. Empty GIT / GIT={}
is WARNING. Meta + a real git op is FATAL.
ROOT= is the same isolation contract as optstr SOURCE=: always under
the component srcdir, escape FATAL before existence, host repo FATAL.
Operations never run in the parent project.
"PC={VERSION=1.2.3;NAME=mylib;DESCRIPTION=My library}"Writes ${BUILDMASTER_INSTALL_LIBDIR}/pkgconfig/<Name>.pc after install.
FATAL if that path already exists. Not a portable package.
PC= only writes the file. It does not demand the pkgconfig
extra. The next Meson or Autotools leaf that reads .pc files is
your problem — see REQUIRE_TOOL.
NOINSTALL + enabled PC= is FATAL (no shared prefix).
executable + enabled PC= is FATAL.
You spent the evening on Linux. Every Meson leaf found libfoo through
a .pc you just wrote. You push. Windows CI does not have pkg-config.
The leaf that “just works” locally dies in dependency('foo') and you
debug a missing tool, not a missing library.
REQUIRE_TOOL exists so that does not happen twice.
buildmaster_component(
codecpack
"Codec pack"
"${CMAKE_SOURCE_DIR}/thirdparty/codecpack"
""
static
codecpack
"REQUIRE_TOOL=pkgconfig;PC={VERSION=1.0;NAME=codecpack}"
)- One id:
REQUIRE_TOOL=pkgconfig - Several:
REQUIRE_TOOL={pkgconfig;…} - Empty / bare /
{}: WARNING, no demand - Unknown id: FATAL. BuildMaster will not pretend the system binary
with the same name is “the extra”. If it is not in
BUILDMASTER_TOOLS_EXTRA_KNOWNandtools/extra/<id>/, it does not exist.
pkgconfig (this release) still prefers a working system pkg-config /
pkgconf. Only if that probe fails does it build the bundled tree (the
Windows case, and any runner that shipped a broken one).
The extra is demanded at register, so it exists before nested
configure. A second REQUIRE_TOOL=pkgconfig is a no-op.
This release ships one extra. More extras are the same contract: a
folder under tools/extra/<id>/, one line in
BUILDMASTER_TOOLS_EXTRA_KNOWN, and _bm_extra_<id>_init. When that
happens you will still write REQUIRE_TOOL=…. You will not get a new
public command.
BUILDMASTER_INITIALIZE_EXTRA_TOOLS is gone. Do not set it.
WHOLE on a static component (or a meta) wraps produced archives so the
linker cannot drop unreferenced objects (plugins, statically registered
codecs). Shared / headers / executable: INFO + ignore.
Default ON for static MSVC / clang-cl archives. After RENAME, *.res
members are removed so /WHOLEARCHIVE does not duplicate resources.
Other toolchains: silent no-op.
Parent CMAKE_INTERPROCEDURAL_OPTIMIZATION is a blunt instrument. It
is fine when every leaf is happy under LTO. It is not fine when one
Meson cc.has_function probe links a bitcode archive and decides the
symbol does not exist. That is a long evening that looks like a missing
dependency.
IPO= is per id. The translator strips foreign LTO tokens. On gcc and
clang it does not write -flto / -ffat-lto-objects back onto
CMAKE_C{XX}_FLAGS. Those compile tokens go to
CMAKE_<LANG>_COMPILE_OPTIONS_IPO so the leaf
CMAKE_INTERPROCEDURAL_OPTIMIZATION module is the only dialect. A
second copy on CMAKE_C_FLAGS used to mix -flto -ffat-lto-objects
with CMake’s -flto=auto -fno-fat-lto-objects; the last token won,
and ld.bfd then dropped objects that nm still listed. LD still
gets -flto (or /LTCG). MSVC and clang-cl still write /GL or
-flto on C/CXX — that is their dialect.
| Written | Meaning |
|---|---|
| (omit) | Inherit the parent (CMAKE_INTERPROCEDURAL_OPTIMIZATION / _RELEASE) and leftover -flto / /GL tokens |
IPO / IPO= / IPO=on |
Thin LTO on this id |
IPO=off |
Strip every IPO token, even if the parent has LTO on |
IPO=fat |
Thin LTO plus -ffat-lto-objects on gcc/clang compile options (and Meson c_args). Not on CMAKE_C_FLAGS. Not on LD. MSVC and clang-cl treat fat as on (/GL+/LTCG, or -flto) |
| anything else | FATAL |
Thin tokens: gcc / clang get -flto on the IPO compile-options string
and on LD. clang-cl gets -flto on C, CXX and LD. MSVC gets /GL on
C/CXX and /LTCG on LD — never /LTCG on the compiler line.
A meta with IPO= stamps members that omitted the key (same
destinations as TOOLCHAIN=). A member that already wrote IPO=
keeps it. Two metas that want different values do not FATAL.
CMake and Meson stages both honour the mode. Meson -Db_lto= follows
it (off → false, on/fat → true, omit → parent). Fat objects
on Meson are extra c_args, not a second b_lto switch.
You do not turn the parent off “just in case”. You write IPO=off on
the leaf that cannot probe under LTO, and leave the rest of the graph
alone.
produced is <name> or <subdir>/<name>. Names are canonical
(post-RENAME). Libraries resolve under LIBDIR. Executables
resolve under BINDIR. buildmaster_link(consumer subdir/name)
links that library archive only.
TOOLCHAIN=gcc|clang|clang-cl|msvc. Nested configure, build and
install use that profile. A meta TOOLCHAIN copies onto members that
did not pin one.
The profile owns compilers and binutils. CMAKE_AR and Meson
[binaries] ar / ld follow the profile (msvc → lib.exe +
link.exe, clang-cl → llvm-lib + lld-link, clang on Linux →
llvm-ar + ld.lld, gcc → binutils ar and the driver linker).
A clang-cl parent does not leave llvm-lib on an msvc leaf.
Paths are absolute before the component toolchain file is written.
Flag dialect is rewritten for that profile before the env runner
refresh: foreign -I/-L/-flto//GL tokens do not leak. Then
IPO= (or the parent) fills CMAKE_<LANG>_COMPILE_OPTIONS_IPO on
gcc/clang, and /GL or -flto on MSVC / clang-cl. See
Interprocedural optimization (IPO).
buildmaster_hook_component(mylib my_after_mylib after_mylib)
buildmaster_hook_graph(my_after_graph after_graph)fn must exist at registration. Alias is the only order key (ASCII
ascending). A hook is not an edge and does not flip eager / deferred.
A component or meta that nothing consumes (buildmaster_link /
buildmaster_depend / host link / host DEPENDS / consumed REPACK
meta) is WARNING after finalize. Fix the graph or accept the noise.
buildmaster_message(<level> "<text>" [<indent>]). Module is always
USER. Levels: STATUS, INFO, WARNING, DEBUG, LOWLEVEL,
FATAL. WARNING and FATAL are never filtered.
BUILDMASTER_LOGLEVEL selects how much is printed.
BUILDMASTER_LOG_NOCOLOR=ON turns ANSI off.
BUILDMASTER_VERBOSE=ON is nested compile --verbose / -v only.
It is not a log level. It is not “print every tool’s configure”.
Tools print Setting up tools: <name> when they actually start.
BUILDMASTER_VERBOSE also enables the configure report after graph
hooks (BuildMaster <version> Configuration:). That is still not
-v for every extra.
BUILDMASTER_FAIL_FAST (env or -D; truthy 1 / ON / TRUE /
YES). On stage failure a marker is written and later stages skip.
Default OFF so independent leaves can still warm a compiler cache.
Parent CMAKE_{C,CXX}_COMPILER_LAUNCHER, CCACHE_DIR and
SCCACHE_DIR are forwarded into nested CMake and Meson.
Adding BuildMaster from a nested project is safe regardless of where its
source directory lives. Pass that directory's actual relative or absolute
path to add_subdirectory, and do so before using any buildmaster_*
command. The first bootstrap owns BUILDMASTER_ROOT, the toolchain dump, and
BUILDMASTER_LINKS_DIR. A second tree loads the parent helpers and returns.
Match versions across submodules; a mismatch is WARNING.
Do not special-case “I am already inside BM”.
Because helpers and links/ belong to the trunk, a nested project
that also uses BuildMaster writes into the same links/ folder.
The parent can buildmaster_link an id the child already built.
That only works if the child declared those nodes with
buildmaster_component / buildmaster_meta — not with a private
target_link_libraries the parent will never see.
| Linux | macOS | Windows | |
|---|---|---|---|
| Generator | Ninja | Ninja | Ninja |
| Meson | yes | yes | yes |
clang-cl / msvc |
— | — | profiles |
| pkg-config on PATH | usually | often | often not |
Windows is why REQUIRE_TOOL=pkgconfig exists. Linux hiding the same
bug is not a feature.
| FetchContent | ExternalProject | BuildMaster | |
|---|---|---|---|
| Configure time | yes | no (build time) | eager when the graph allows |
| Meson | you write it | you write it | first-class |
Shared prefix + .pc |
DIY | DIY | prefix + optional PC= |
| Graph edges | include order | DEPENDS |
depend / link |
| Order-independent declare | no | no | yes |
| Extra host tools | hope PATH | hope PATH | REQUIRE_TOOL |
| Transitive BM deps across processes | no | no | links/ |
Static PRIVATE closure (pq on the test line) |
DIY / REPACK |
DIY | links/<id>_static.txt |
From the BuildMaster repo:
rm -rf build/harness && cmake -S .github/tests/harness -B build/harness -G Ninja \
&& cmake --build build/harness --target run_buildmaster_mainThat is smoke + negative + consumer (consumer_nested and a wiped
consumer_ci bindir). The three older targets still exist if you
need them split.
MIT. See LICENSE.
If this saved you from a third POST_BUILD rename script, a star is
the polite nod. A well-aimed issue beats a vague “it broke”. Pull
requests that keep the DSL at ten commands are the ones that land.
I wrote this because the alternative was another private graph in every product. Maintaining that difference takes evenings.
Use it. Break it on purpose. Tell me which sentence in this file lied.