Variant builds

Many projects need to maintain one set of requirements that describes several product variants — different hardware platforms, customer editions, build configurations, or deployment targets — and then produce a tailored output for each one.

ubCode supports this through what is often called a “150% model”: a single source of truth that contains the union of all variants (the 150%), from which each build selects the relevant 100%. Instead of copying documents per variant (and keeping the copies painfully in sync), you keep one model and let the build context decide which values, links, and content apply.

See also

variants-demo is a small, runnable project that demonstrates the workflow described here.

Note that the demo also previews a not-yet-released if directive for conditional compilation (including or excluding whole content blocks per variant). That layer is planned but not yet available in ubCode; this guide covers the mechanisms that exist today.

The build context

A “build” is described by two pieces of configuration:

build_tags

A list of tags describing the current target (for example the builder or environment). These mirror Sphinx’s tags and are available as the build_tags variable in filter expressions.

Variant data

A nested, read-only key-value store exposed under the var.* namespace, holding the parameters of the current variant (platform, architecture, enabled features, …).

Both can be set in ubproject.toml and overridden per build (see Producing a build per variant below), so the same model resolves differently depending on the target.

Three building blocks

Variant builds combine three mechanisms. They share the same filter expression language (see Writing a filter) and the same var.* / build_tags context.

1. Variant data and var.* filtering

The variant’s parameters live in variant data, declared inline or loaded from a JSON file:

build_tags = ["html"]

[needs.variant_data]
platform = "arm"
archs = ["arm", "x86"]

[needs.variant_data.build]
compiler = "clang"
features = ["networking", "logging"]

The var.* namespace can then be used in any filter — needextend and needimport directives, external_needs filters, conditional defaults (predicates), and the filters inside variant functions:

.. needextend:: var.platform == "windows"
   :status: supported

.. needimport:: shared.json
   :filter: "arm" in var.archs

Because var.* is global to the build (it does not depend on the current need), it is ideal for build-wide switches.

2. Injecting values with <{ ... }>

A variant data reference substitutes a var.* value directly into a field or link value. The field (or link) must opt in with parse_variants = true:

[needs.fields.arch]
schema = {type = "string"}
parse_variants = true
.. req:: Bootloader
   :id: REQ_001
   :arch: <{ var.platform }>

.. req:: Build banner
   :id: REQ_002
   :arch: built for <{ var.platform }>

This is a plain lookup — there is no condition, just a value taken from the current variant. See Referencing variant data in field values (<{ ... }>) for the embedding rules, type-checking, and the diagnostics emitted for unknown keys or type mismatches.

3. Choosing values with <<...>>

A variant function selects between candidate values using conditional logic. The first matching expression wins; the final comma-separated value is the fallback:

.. req:: Power management
   :id: REQ_003
   :status: <<[var.platform == "windows"]: active, inactive>>
   :priority: <<['html' in build_tags]: web_critical, medium>>

Unlike <{ ... }> (a direct substitution), <<...>> evaluates one or more filter expressions and picks the corresponding value.

Producing a build per variant

The point of a 150% model is to build it more than once, once per variant, by swapping the build context.

Keep the shared model in your sources and the per-variant parameters in separate files, for example variants1.json and variants2.json:

variants1.json
{"platform": "arm", "build": {"compiler": "clang"}}
variants2.json
{"platform": "x86", "build": {"compiler": "gcc"}}

With ubc, select the file with a -c/--config override (repeatable, accepts any ubproject.toml snippet):

# Default build (uses variant_data / variant_data_file from ubproject.toml)
ubc build needs --pretty --output needs.arm.json

# Build the second variant by overriding the data file
ubc build needs --pretty --output needs.x86.json \
  -c "needs.variant_data_file = 'variants2.json'"

The same override works for ubc build index and other commands. Individual values can be overridden directly too, using TOML dotted-key syntax — -c "needs.variant_data.platform = 'x86'" — and -c may be repeated to apply several overrides.

When building with Sphinx-Needs itself, pass the equivalent Sphinx config value with -D:

sphinx-build -E . _build -D needs_variant_data_file=variants2.json

Comparing variants

Because each variant is just a different build context, you can use ubc diff to see exactly how the resolved needs differ between two variants.

The --config mode of ubc diff compares the current project against the same project with a configuration override applied — perfect for diffing one variant against another:

# Compare the default variant against variants2.json
ubc diff -c "needs.variant_data_file = 'variants2.json'"

The output reports which needs changed — added, removed, or modified fields and links — making it easy to review the impact of a variant or to catch unintended differences between targets.

See also