Skip to content

Sharing project configuration with documentation bundles #807

Description

@AlexanderLanin

Status: proposed. This document describes a future implementation; the new
configuration interface is not implemented yet.

Prerequisite: named documentation target families. Implement
that independent naming and compatibility migration before this proposal.

Problem and intended behavior

The public docs() and docs_bundle() macros live in docs.bzl.
Each source-bearing bundle has a local Needs export built from its own sources.
Standalone bundle exports currently generate a baseline Sphinx configuration
with the bundle name, an empty project_url, and the default metamodel. The root
bundle can use its project's configuration, but its local export does not receive
the explicit docs(metamodel = ...) argument. Bundle-local inventories also
deliberately export an empty project_url, even when Sphinx knows a project URL.

The proposal makes selected settings from docs() available to bundle-local
exports through internal targets associated with a named docs() invocation.
Configuration belongs to the project that defines and publishes the bundle.
Mounting that bundle into another
project does not change its standalone export's settings or publication metadata.
The combined project build continues to use the mounting project's configuration.

Initially, the shared settings are project, project_url, metamodel, and deps.
Publication metadata also includes the bundle's full placement path within its
defining project's documentation tree.

Public interface

Settings remain arguments of the named docs() invocation established by
name_concept.md. Its public owner label is //package:<name>;
its canonical incremental run target is //package:<name>.incremental.

Bundles refer to the named docs() invocation through a new docs argument.
Users do not refer to configuration targets. Internally, the macro resolves the
owner to <name>.__internal__.docs_config and other helpers. Neither a public
docs_config target nor a separate configuration macro is introduced.

# //BUILD
load("@score_docs_as_code//:docs.bzl", "docs")

docs(
    name = "docs",
    project = "Example",
    project_url = "https://example.org/example",
    metamodel = "//:metamodel.yaml",
    deps = ["//tools:documentation_helpers"],
    bundles = [{
        "bundle": "//components/engine:docs_bundle",
        "mount_at": "components/engine",
    }],
)

The default owner of a bundle is the root docs() invocation:

# //components/engine/BUILD
load("@score_docs_as_code//:docs.bzl", "docs_bundle")

docs_bundle(
    name = "docs_bundle",
    source_dir = "docs",
    visibility = ["//visibility:public"],
    # docs defaults to //:docs in this bundle's repository.
)

The default is the declaring repository's //:docs, including when that
repository is an external Bzlmod dependency. It must not resolve to the macro
implementation's repository or to the repository mounting the bundle. There is
no search for an owner in ancestor packages.

For a project declared with docs(name = "site", ...) in //project, select
that invocation explicitly:

docs_bundle(
    name = "docs_bundle",
    source_dir = "docs",
    docs = "//project:site",
)

The root bundle generated by docs() always selects that invocation, including
when it has a custom name or is declared in a subdirectory. Selecting an owner on
an aggregating bundle does not implicitly change its children's owner selections.

Use docs = None for a standalone export without an owning publication:

docs_bundle(
    name = "standalone",
    source_dir = "docs",
    docs = None,
)

Owner selection has the following contract:

Case Behavior
Configured local export Use the selected owner's shared settings and placement.
Missing owner or its generated helpers Fail dependency analysis; declare the named docs() invocation, select another owner, or use docs = None.
Bundle absent from selected owner's mount tree Fail with a diagnostic identifying the bundle and owner; do not guess a publication path.
Owner omits project_url Fail the configured local export with a diagnostic requesting an explicit docs(project_url = ...).
Owner omits project Use the bundle name for its local export.
Owner omits metamodel Use the shipped default metamodel.
Owner omits deps Use the standard documentation dependencies.
docs = None Retain standalone baseline settings and dependencies, and an empty exported URL.
Bundle has no direct sources Continue to create no local Needs export.

Placement and required-value checks apply when a configured local export is
analyzed. Merely composing source bundles does not require their export
configuration to be available.

References to the owner

Use the prerequisite's <name>.<role> family: the root bundle is
:<name>.bundle and the project-wide inventory is :<name>.needs_json.
This proposal adds the internal helpers <name>.__internal__.docs_config and
<name>.__internal__.sphinx_build; it does not introduce another naming scheme
or another set of compatibility aliases.

The docs argument accepts a label identifying an actual docs() invocation,
not an arbitrary alias to its runnable target. Normalize it in the declaring
repository before deriving sibling helper labels, preserving its repository and
package. Consumers never need to spell .__internal__. names.

Existing conf.py support

Keep the current conf.py support and precedence for project-wide builds. In
particular, a project's conf.py remains authoritative for its project name and
URL when present. The shared configuration only represents docs() arguments;
it does not execute or extract values from conf.py.

Bundle-local exports take shared fields from docs() arguments. Child exports
use the baseline configuration plus those fields. The root local export retains
its existing project configuration for other settings. Authors must keep shared
arguments aligned with overlapping values in a legacy conf.py. Supplying a URL
only in conf.py is insufficient for a configured local export.

Removing conf.py support is a future direction, outside this proposal. Python
dependencies explicitly supplied through docs(deps = ...) are shared with child
exports. Other Sphinx settings and external Needs imports are not automatically
inherited. Making a Python package available does not automatically enable it as
a Sphinx extension.

Dependency graph and implementation

Macros declare targets during Bazel's loading phase. Rules consume dependency
providers during analysis. Adding fields to a child's provider makes information
available to its consumers; it does not inject a parent's settings into that
child. Dependency cycles are invalid. See the
Bazel rules documentation.

Use separate source-bundle and export targets. In this diagram, an arrow means
"depends on":

flowchart TD
    Export["Bundle-local Needs export"] --> Adapter["Bundle Sphinx configuration adapter"]
    Export --> Sources["Bundle's own sources"]
    Export --> Runtime["Owner :name.__internal__.sphinx_build"]
    Runtime --> Deps["Owner deps + standard documentation dependencies"]
    Adapter --> Config["Owner :name.__internal__.docs_config"]
    Config --> Deps
    Config --> Root["Owner :name.bundle"]
    Root --> Child["Child source-bundle targets"]
    Sources --> Child
Loading

The source-bundle targets must not depend on the internal configuration or local
Needs exports. This allows the configuration target to inspect the composed source
tree without introducing a cycle. Building a local export may analyze the owner's
source-bundle graph, but must not run the owner's complete Sphinx build or other
bundles' local exports.

The public owner label is an API-level reference to the macro invocation. The
export's generated rules depend on the derived internal helpers, not on the
owner's runnable documentation target. Depending on the runnable target would
pull in unrelated build/runtime inputs and could introduce a cycle.

Internal naming means these helpers are implementation details, not that they
can have package-private visibility: local exports in other packages need to
depend on them. Give them visibility to packages in the defining repository.
Cross-repository consumers keep referencing the owner invocation defined in the
bundle's own repository; they do not select a different repository as its owner.

Configuration and placement providers

Introduce an internal DocsConfigInfo provider with the project name, project
URL, optional metamodel File, resolved Python dependency information for deps,
and a mapping from bundle labels to their full placement paths. The configuration
rule depends on the root source bundle, the optional metamodel target, and the
shared Python dependencies. Its outputs must not forward the entire owner's
source tree as inputs to every local export.

Extend source-entry metadata in bzl/bundle_rules.bzl with
the declaring bundle's Bazel label. Preserve that identity when entries are
rebased through nested bundles. Use label identity, rather than filesystem paths
or target-name strings, to look up a bundle's placement.

The configuration rule derives placements from the owner's composed entries.
For example, a parent mounted at components containing a child mounted at
engine gives that child the path components/engine. attach_to controls
navigation and does not contribute to this path. The owner's root bundle has an
empty placement path. Preserve the existing rejection of a source directory
included through more than one bundle path.

Only the selected defining project's placement determines a local export's
metadata. A downstream mount at dependencies/example does not prefix or replace
that publication path.

Sphinx integration

An analysis-phase adapter consumes DocsConfigInfo and the exporting bundle's
label to generate Sphinx configuration inputs. Macros only wire these targets
together; they do not attempt to read providers.

Declare an owner Sphinx runtime at <name>.__internal__.sphinx_build, using the
owner's deps plus the standard documentation dependencies. Apply the existing
dependency completion and consistency checks when declaring that runtime. Resolve
relative dependency labels in the owner's package and repository, not in each
child's package.

Let the local Needs wrapper accept this existing Sphinx executable instead of
unconditionally creating a new binary with baseline dependencies. Reuse the owner
runtime for configured local exports and the project-wide Needs build. A
standalone export continues to create its own baseline runtime. Keep executable
providers and transitive runfiles intact; serializing a list of dependency labels
into generated Python configuration would not make those packages importable.
The runtime must not depend on configuration, source-bundle composition, or any
Needs export. Export-specific inputs such as the metamodel remain action inputs.

Apply the shared settings to both child and root local exports. Preserve the
root's existing configuration for settings outside the shared fields. Keep local
exports limited to direct sources, using the existing source adapters and local
export policy. This change does not add cross-bundle Needs imports.

Represent the metamodel as a real dependency throughout the adapter and Sphinx
action. Expose it through the required action inputs and runtime data rather than
passing only a path string. Generated metamodels and files from external
repositories must work under sandboxed execution. Configuration adapters must
also preserve the source-root layout expected by the existing Sphinx rules.

Exported URLs and compatibility

Configured bundle-local inventories export the owner URL and placement
separately:

{
  "project_url": "https://example.org/example",
  "bundle_mount_path": "components/engine"
}

These are top-level inventory fields. The root local export uses an empty
bundle_mount_path. An unconfigured standalone export keeps an empty
project_url and an empty placement path.

Keep document names inside local inventories relative to their bundle. Update
both inventory import paths in
external_needs.py to construct
the base URL from the owner URL, the existing main publication directory, and
the bundle mount path, in that order:

<project_url>/main/<bundle_mount_path>/<local-document>.html#<need-id>

For details in the example bundle, the resulting link is:

https://example.org/example/main/components/engine/details.html#<need-id>

Normalize separator boundaries without dropping the project URL's path prefix.
Do not place main after the bundle path or prefix document names a second time.
Generalizing the existing main publication convention is outside this proposal.

New consumers treat a missing bundle_mount_path as an empty path, preserving
existing project inventories. Older consumers do not understand the placement
field, so mount-aware local inventories require an updated consumer. This changes
the current deliberate suppression of the owner URL in local exports.

For migration, bundles belonging to subprojects select their named owner
explicitly. Standalone bundles either become part of an owner's publication tree
or set docs = None. Projects relying on conf.py supply shared values through
docs() as well. Existing source composition remains independent of these local
export changes.

Acceptance scenarios

Implementation coverage should exercise behavior through the existing public
macro integration suite and focused exporter/importer tests:

  • A custom metamodel is enforced in project-wide, root-local, and child-local
    exports, including a failure that the shipped default would not detect.
  • Generated and external metamodel files are available inside sandboxed builds.
  • A Python dependency supplied only through the owner's deps is importable in
    project-wide, root-local, and child-local Sphinx runtimes, including its
    transitive dependencies and runtime data. Relative labels resolve at the owner.
  • The existing dependency consistency checks still apply, and standalone exports
    retain the standard dependencies.
  • Direct and nested mounts produce the expected metadata and external links;
    root placement, trailing URL separators, and bundle-relative document names
    are covered.
  • A bundle loaded through Bzlmod resolves its default configuration in its own
    repository. A consuming project's settings and different mount location do
    not change the defining project's export metadata.
  • A named subproject owner reference works, and each docs() root selects its
    own invocation automatically. Bundle declarations never reference internal
    target names.
  • Two named invocations in one package keep their shared settings, runtimes, and
    placement metadata separate. Target naming and legacy aliases are covered by
    the independent naming prerequisite.
  • Missing configuration, missing placement, and missing explicit owner URL fail
    configured local exports; docs = None preserves standalone behavior.
  • Source-only composition still works without evaluating local export
    configuration, and aggregators still have no local export.
  • The dependency graph is cycle-free. Building one local export does not execute
    the owner's full documentation build or sibling local exports and exports only
    its own Needs.
  • Both inventory import paths handle the new placement field and old inventories
    without it. Existing project-wide conf.py precedence remains intact.

For this concept-only change, review examples against the current source and
check Markdown structure, copyright, and whitespace. Bazel builds are not needed
until the implementation changes.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions