Skip to content

Dependency Linkage

Two orthogonal axes determine how a dependency ends up in your build product:

Axis CCGO.toml field Values
What you produce [build].link_type static, shared, both
How a dep relates to you [[dependencies]].linkage (with [build].default_dep_linkage as project-wide default) shared-external, static-embedded, static-external

Linkage values

  • shared-external — the dep stays as its own .so/.dylib/.dll and your binary records a runtime dependency (DT_NEEDED on ELF). Default for shared consumers when the dep can produce a shared artifact. Best for app-internal sharing across multiple consumers — eliminates the per-consumer copy of the dep that fat archives produce.
  • static-embedded — the dep's .a is archived into your binary at link time. Your binary becomes self-contained; multiple consumers each carry their own copy of the dep code. Default fallback when the dep only ships a .a.
  • static-external — the dep stays as a separate .a and your .a records the dependency without merging. Only valid for static consumers; the final executable's linker reconciles symbols at exe link time. The "thin chain" model.
  • shared-embeddeddoes not exist. Trying to set it produces a parse error pointing at the two valid alternatives. A .so cannot be archived into another .so.

Valid build-as × linkage combinations

The three linkage values have different validity rules depending on which consumer type (build-as) is producing the artifact. Invalid combinations are rejected at config parse time with a clear error message.

General hint (linkage / default_dep_linkage)

When set on the linkage or default_dep_linkage field (which applies to all consumer shapes), the allowed values depend on build-as:

build-as shared-external static-embedded static-external
shared ✅ (default)
static ✅ (default)
both

build-as = both is the intersection: a value must be valid for both consumer shapes. static-embedded violates the static side; static-external violates the shared side. Only shared-external is safe to specify unconditionally across both shapes.

Consumer-specific fields (linkage_on_shared / linkage_on_static)

Use these fields to set different hints per consumer shape without conflicting:

Value linkage_on_shared linkage_on_static
shared-external
static-embedded
static-external

linkage_on_shared never permits static-external — it would leave unresolved symbols in the .so. linkage_on_static never permits static-embedded — static archives cannot embed other archives.

Decision matrix

Consumer Dep provides Hint Result
shared only .a (any) static-embedded (forced; shared-external hint is an error)
shared only .so (any) shared-external (forced; static-embedded hint is an error)
shared both / source absent shared-external
shared both / source shared-external shared-external
shared both / source static-embedded static-embedded
shared any static-external error — banned for shared consumers
static only .a absent / static-external static-external
static only .a shared-external error — dep has no .so
static only .so absent / shared-external shared-external
static only .so static-external error — dep has no .a
static both / source absent / static-external static-external
static both / source shared-external shared-external
static any static-embedded error — static archives cannot embed

When to override

Most projects don't need to set linkage at all. The default (shared-external for shared consumers when the dep ships a .so) avoids bloat across multiple sibling consumers without surprises.

Set linkage = "static-embedded" per-dep when: * The dep is small and you want self-containment (one fewer .so to ship). * You're publishing an SDK to external developers via Maven/CocoaPods who won't have a way to install transitive deps independently. * The dep only ships .a and you want to silence the auto-fallback notice in build logs.

Build-time logging

ccgo emits a STATUS line per dep at CMake configure time:

[ccgo] stdcomm:    linkage=shared-external (DT_NEEDED to dep.so)
[ccgo] tinyhelper: linkage=static-embedded (.a archived into target)
[ccgo] zstd:       linkage=static-embedded (auto, no .so available)

(auto, ...) indicates the resolution came from a fallback rather than an explicit linkage field; (auto, no .so available) specifically means the dep doesn't ship a shared form, so embedding is the only choice.

Example

[package]
name = "logcomm"
version = "1.0.0"

[build]
link_type = "shared"                 # I produce a .so
default_dep_linkage = "shared-external"  # default for my deps

[[dependencies]]
name = "stdcomm"
version = "25.2.9519653"
# Default linkage applies → shared-external (libstdcomm.so stays separate)

[[dependencies]]
name = "tinyhelper"
version = "0.3.0"
linkage = "static-embedded"          # explicit: archive into liblogcomm.so

Reads as: "I'm a shared library. By default my deps are external. tinyhelper is the exception — bake it into me." Three semantics, three settings, no ambiguity.

Source-only dependencies

When a dep ships only source code (the .ccgo/deps/<name>/ dir contains src/ and CCGO.toml but no lib/<platform>/ artifacts for the platform being built), ccgo build automatically recurses: it spawns ccgo build <platform> --build-as <derived> inside the dep before it resolves linkage, then symlinks dep/lib/<platform>/ to the new build output so the consumer's FindCCGODependencies.cmake walks find the artifacts.

The --build-as value is derived from the consumer's resolved hint for that dep:

Consumer's hint for the dep Recursive --build-as
shared-external (default) shared
static-embedded / static-external static
(no hint) both

A dep's own [build].link_type declaration does not dictate what gets produced during a recursive materialize — the consumer's needs do. If you set --linkage stdcomm=static-embedded on a build that has a source-only stdcomm, you get a .a. If you flip to --linkage stdcomm=shared-external, ccgo rebuilds stdcomm as a .so on the next build.

Caching

The materialize step persists a per-platform, per---build-as fingerprint at ~/.ccgo/cache/<project-path>/<dep>/<platform>_<build_as>.fingerprint (outside the project tree, so no generated files appear in your working copy). The fingerprint is a SHA-256 over (sorted source-tree mtimes + sizes + paths) + the CCGO.toml content + the requested --build-as. Subsequent builds skip the recursive spawn when the fingerprint matches AND lib/<platform>/ still has artifacts. Splitting the sidecar by build_as prevents two parallel builds of the same path-source dep from racing on a shared sidecar when one wants --build-as shared and the other wants --build-as static.

Behavior matrix:

State Action
no lib/<platform>/, no fingerprint Spawn build, write fingerprint
no lib/<platform>/, fingerprint exists Spawn build (lib was deleted)
lib/<platform>/ exists, fingerprint matches Skip (cache hit)
lib/<platform>/ exists, fingerprint mismatches Spawn build (source changed)
lib/<platform>/ exists, no fingerprint Trust prebuilt, write fingerprint

The "trust prebuilt" path matters for fixtures and projects that ship hand-curated lib/<platform>/ layouts (e.g. xcframework symlinks). Those get a fingerprint stamped on the first invocation and then participate in normal source-change invalidation from then on.

What gets propagated to the recursive build

The recursive ccgo build call inherits:

  • --release (from the parent's release flag)
  • --arch <csv> (lowercased; matches the parent's --arch)
  • --build-as <variant> (derived from hint as above)

It does not inherit:

  • --linkage — the dep's own [[dependencies]] are decided by its own CCGO.toml. A consumer's per-dep linkage hint applies only to that consumer's relationship with the dep, not to the dep's relationship with its own deps.

Failure mode

If the recursive build fails, the parent ccgo bails with the dep name and a reproduction command:

recursive `ccgo build` for source-only dep 'stdcomm' (--build-as shared) failed
with exit code Some(1). The dep at .ccgo/deps/stdcomm could not be compiled —
check its CCGO.toml and try `ccgo build macos --build-as shared` inside that
directory to reproduce.

Source vs binary precedence in the consumer's CMake template

When a path-source dep ships both src/ and pre-built lib/<platform>/ artifacts (the latter being what the bridge populates after a successful materialize spawn), the consumer's CMake template now skips the inline source compilation if the resolved linkage is shared-external and the bridge has placed a usable shared library at the expected depth. Concretely:

  • consumer/.ccgo/deps/<name>/lib/<platform>/shared/<name>.xcframework/ exists (Apple) or lib/<platform>/shared/<arch>/lib<name>.so exists (Android/OHOS/Linux) → the dep's src/ is skipped in the <consumer>-deps aggregate. The consumer's main shared target picks up libleaf.dylib / libleaf.so via DT_NEEDED / LC_LOAD_DYLIB instead.
  • static-embedded linkage continues to compile the dep's source into the consumer's archive (or link against the pre-built .a), as expected.

Falling back to inline source compilation only happens when no shared artifacts are present and the linkage hint either asks for or ends up at static-embedded. This is the contract the linkage matrix expects.

Cross-platform bridge

The bridge step uses NTFS directory junctions (mklink /J) on Windows in place of Unix symlink. Junctions don't require admin or Developer Mode and work on the same volume as the dep — which the cmake_build/ tree always is. They behave identically to symlinks for the EXISTS / file(GLOB ...) walks that FindCCGODependencies.cmake performs.

See also

  • dependency-resolution.md — how ccgo finds and fetches deps before linkage decisions get made.
  • The Rust source: src/build/linkage.rs — pure decision matrix and filesystem scanner. The decision table above is mirrored exactly in the unit tests.