Skip to content

[question] Proposal: Allow header-library packages to define default propagation and package_id policies #20099

Description

@gnaloaixey

What is your question?

Background

In Conan 2, package_type = "header-library" accurately describes a package that only contains headers. For this kind of package, direct consumers usually need to receive the package include directories in order to compile correctly.

However, in some projects, a header-only library may be used internally by a package, while its include directories should not be propagated further downstream. For example:

  • Package A directly uses headers from eigen/3.3.8.
  • Consumer B of package A should not automatically receive Eigen's include path.
  • Eigen is an implementation detail of A, not part of A's public API.

Today this can be expressed in A's recipe as follows:

def requirements(self):
    self.requires("eigen/3.3.8", transitive_headers=False)

This prevents Eigen's headers from being propagated further downstream.

Similarly, when a header-only library is only an implementation detail, its recipe revision or package revision may not always be expected to affect the binary identity of the current package. For example, after A depends on eigen/3.3.8, Conan information may contain something like:

eigen/3.3.8#<recipe-revision>

For shared library dependencies, the default package ID behavior is usually closer to ABI compatibility semantics. For header-library packages, since the header contents directly participate in compilation, Conan may reasonably lean toward including recipe revisions or similar hashes in the upstream package identity or lock information. This is valuable for reproducible builds, but in some internal distribution scenarios we want the package identity to be precise only up to the declared version, such as eigen/3.3.8, instead of being fixed to a specific recipe hash.

Current problem

The control point is currently mostly on the requiring side. In other words, every package that depends on eigen/3.3.8 needs to remember to explicitly set transitive_headers=False.

This creates a risk across the dependency chain: if any intermediate package forgets to set it, the header include path may keep propagating downstream, causing deeper consumers to unexpectedly see that header-only library.

This behavior can lead to several issues:

  1. The downstream compile environment is unintentionally polluted.
  2. Consumers may accidentally use headers that are not part of their direct dependencies.
  3. The boundary between public API and implementation detail becomes unclear.
  4. In large dependency graphs, requiring every intermediate package to set the correct traits is easy to miss.
  5. Package authors cannot centrally express in the package itself that "my headers should not be propagated by default".
  6. Package authors cannot centrally express in the package itself that "by default, I should affect upstream package_id only by version, not by a specific recipe/package hash".

For some header-only libraries, the package author is in the best position to know whether the package should usually be used as a public header dependency, or more commonly as a private implementation detail. Therefore, requiring every consumer to repeat the propagation policy is not always ideal.

Currently, consumers can reduce the package ID impact of an implementation-detail dependency like this:

def requirements(self):
    self.requires(
        "eigen/3.3.8",
        transitive_headers=False,
        package_id_mode="full_version_mode",
    )

If the dependency should not affect the current package ID at all, a more aggressive mode can be used:

def requirements(self):
    self.requires(
        "eigen/3.3.8",
        transitive_headers=False,
        package_id_mode="unrelated_mode",
    )

But this still requires every requiring package to remember to set it. If an intermediate package forgets, downstream consumers may still see unexpected header propagation, or the package ID / conaninfo may still show a header-only dependency fixed to a specific hash.

Proposal

It would be useful for Conan to support default header propagation and default package ID impact policies defined by the header-library package itself.

For example, Conan could consider supporting something like:

class EigenConan(ConanFile):
    name = "eigen"
    version = "3.3.8"
    package_type = "header-library"

    default_transitive_headers = False
    default_package_id_mode = "full_version_mode"

Or express it through package_info() / cpp_info:

def package_info(self):
    self.cpp_info.includedirs = ["include"]
    self.cpp_info.transitive_headers = False
    self.cpp_info.default_package_id_mode = "full_version_mode"

The intended semantics would be:

  • Direct consumers of the package can still receive the include directories.
  • By default, the package's header usage requirement is not propagated further downstream.
  • By default, when this package affects an upstream package ID as a dependency, it is considered only up to the package version, instead of being fixed to a recipe revision or package revision hash.
  • The requiring package can still explicitly override the default behavior, for example:
def requirements(self):
    self.requires(
        "eigen/3.3.8",
        transitive_headers=True,
        package_id_mode="full_recipe_mode",
    )

This would preserve final control for the requiring package, while allowing the package author to provide safer defaults that better match the intended package design.

Expected behavior example

Assume the dependency graph is:

B -> A -> eigen

eigen recipe:

class EigenConan(ConanFile):
    name = "eigen"
    version = "3.3.8"
    package_type = "header-library"

    def package_info(self):
        self.cpp_info.includedirs = ["include"]
        self.cpp_info.transitive_headers = False
        self.cpp_info.default_package_id_mode = "full_version_mode"

A recipe:

def requirements(self):
    self.requires("eigen/3.3.8")

Expected result:

  • A can use Eigen headers normally.
  • B does not automatically receive Eigen's include path just because it depends on A.
  • A's package ID is affected by the version eigen/3.3.8 by default, instead of being fixed to eigen/3.3.8#<recipe-revision>.
  • If A exposes Eigen types in its public headers, A can explicitly write:
def requirements(self):
    self.requires("eigen/3.3.8", transitive_headers=True)

This overrides the default policy declared by the Eigen package itself.

If A wants every Eigen recipe revision change to affect its own package ID, it can explicitly write:

def requirements(self):
    self.requires("eigen/3.3.8", package_id_mode="full_recipe_mode")

Have you read the CONTRIBUTING guide?

  • I've read the CONTRIBUTING guide

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions