16 KiB
Library reuse + public registry
Design plan for two interlocking features:
- Library reuse — cargoxx-built libraries are consumable by other
cargoxx projects, by plain CMake projects (
find_package), and by plain clang invocations (pkg-config). - Public registry + publish — a Gitea-hosted package registry
(
cargoxx-pkgs), acargoxx publishCLI, and self-hosted CI that validates PRs, builds, pushes derivations to a binary cache, and auto-merges.
Phase 1 covers library reuse end-to-end with local path and git deps. Phase 2 layers the registry on top, with cache-backed substitution so consumers don't recompile.
Part 1 — reusable libraries
1.1 Producer install layout
$out/
├── bin/ # binaries (existing path)
├── lib/
│ ├── libmylib.a # archive of impl bits
│ ├── pkgconfig/mylib.pc # pkg-config descriptor
│ └── cmake/mylib/
│ ├── mylibConfig.cmake # entry point for find_package
│ ├── mylibConfigVersion.cmake # semver gate
│ └── mylibTargets.cmake # IMPORTED tgt + CXX_MODULES FILE_SET
└── include/mylib/ # public headers + source-form modules
Covers three consumption paths:
- cargoxx → cargoxx: consumer's CMakeLists emits
find_package(mylib CONFIG REQUIRED)+ link againstmylib::mylib.$outis onCMAKE_PREFIX_PATHviabuildInputs. - plain CMake: same
find_packagecall, manualHINTSif not on the default prefix path. - plain clang + pkg-config: works for non-module libraries
(header-only or
.awith extern API). Module libraries require a CMake-aware consumer — industry-wide pkg-config has no story for module BMIs.
1.2 Codegen changes (src/codegen/cmake.cpp)
When layout.library is set, append:
include(GNUInstallDirs)
include(CMakePackageConfigHelpers)
install(TARGETS mylib
EXPORT mylibTargets
FILE_SET CXX_MODULES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/mylib
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
install(EXPORT mylibTargets
FILE mylibTargets.cmake
NAMESPACE mylib::
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib)
# Config.cmake.in written inline so we don't need an off-tree template file.
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake.in [[
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
@FIND_DEPENDENCY_BLOCK@
include("${CMAKE_CURRENT_LIST_DIR}/mylibTargets.cmake")
check_required_components(mylib)
]])
configure_package_config_file(
${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib)
write_basic_package_version_file(
${CMAKE_CURRENT_BINARY_DIR}/mylibConfigVersion.cmake
VERSION ${PROJECT_VERSION} COMPATIBILITY SameMajorVersion)
install(FILES
${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/mylibConfigVersion.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib)
# pkg-config descriptor — also inline.
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/mylib.pc.in [[
prefix=@CMAKE_INSTALL_PREFIX@
exec_prefix=${prefix}
libdir=${prefix}/lib
includedir=${prefix}/include
Name: @PROJECT_NAME@
Version: @PROJECT_VERSION@
Description: @PROJECT_DESCRIPTION@
Cflags: -I${includedir}
Libs: -L${libdir} -lmylib
]])
configure_file(${CMAKE_CURRENT_BINARY_DIR}/mylib.pc.in
${CMAKE_CURRENT_BINARY_DIR}/mylib.pc @ONLY)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/mylib.pc
DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig)
@FIND_DEPENDENCY_BLOCK@ is computed by codegen — one find_dependency(X)
line per cargoxx-source dep, so the transitive graph reconstitutes when
a consumer does find_package(mylib …).
Binaries also get an install rule so cmake --install handles
everything in one shot:
install(TARGETS mylib_bin RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(TARGETS tool RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
1.3 buildCppPackage.installPhase switches to cmake --install
installPhase = ''
cmake --install build/release --prefix $out
'';
Replaces the cp -a build/release/bin/. $out/bin/ hack — now bins,
library archive, headers, cmake config, pc file all land in the right
place from one install command.
1.4 Manifest schema — discriminated dep source
Backward-compat (string form unchanged):
[dependencies]
sqlite = "*" # nixpkgs / linkdb (today)
fmt = { version = ">=10" } # nixpkgs explicit (today)
mylib = { path = "../mylib" } # cargoxx local path ← NEW
remote = { git = "https://gitea/me/remote", rev = "abc123" } # cargoxx git ← NEW
shared = { version = "1.0", registry = "cargoxx" } # cargoxx registry ← NEW
manifest::Dependency grows discriminated source fields. Parser rejects
unknown keys in dep tables (today silently ignored).
1.5 Lockfile schema additions
For cargoxx-source deps the lockfile carries source provenance and the
recipe shape (find_package, targets) — codegen consumes the latter
unchanged, so cargoxx deps look like any other linkdb recipe to the
emitted CMakeLists.
Path deps: record relative path.
Git deps: record (url, commit, sha256).
Registry deps: record only (registry_rev, registry_attr); the source
URL + sha256 live in the registry repo, not the consumer's lockfile.
1.6 buildCppPackage recursion
buildCargoxxDep = p:
if p.source_kind == "path" then
buildCppPackage { src = src + ("/" + p.path); }
else if p.source_kind == "git" then
let depSrc = pkgs.fetchgit {
inherit (p) url;
rev = p.commit;
hash = p.sha256;
};
in buildCppPackage { src = depSrc; }
else if p.source_kind == "registry" then
# Pull the prebuilt $out from the registry flake — no source fetch,
# no recompile. The registry's `packages.<system>.<attr>` derivation
# is content-addressed and substituted from the binary cache.
let registry = builtins.getFlake
"git+https://gitea/.../cargoxx-pkgs?rev=${lock.registry_rev}";
in registry.packages.${system}.${p.registry_attr}
else throw "unknown source_kind: ${p.source_kind}";
cargoxxDepDrvs = map buildCargoxxDep
(builtins.filter (p: p ? source_kind) lock.package);
cargoxxDepDrvs join buildInputs. nixpkgs's cmakeConfigurePhase
automatically prepends every buildInputs $out to CMAKE_PREFIX_PATH,
so consumer find_package(<dep> CONFIG REQUIRED) resolves without
extra plumbing.
1.7 Resolver flow for cargoxx-source deps
manifest dep → cmd_build::resolve_dep()
├─ source kind == Nixpkgs/Auto: existing resolver chain
├─ source kind == CargoxxPath:
│ read ${path}/Cargoxx.toml, take [package].name/version,
│ synthesize Recipe { find_package = "<name> CONFIG REQUIRED",
│ targets = ["<name>::<name>"] }
├─ source kind == CargoxxGit:
│ fetch the repo at rev (resolver cache),
│ same as CargoxxPath against the checkout
└─ source kind == CargoxxRegistry:
fetch the registry's recipe TOML, take name + version,
synthesize the same Recipe shape.
The recipe shape — find_package(<name> CONFIG REQUIRED) plus
<name>::<name> target — matches what the producer's installed
<name>Config.cmake defines. Codegen needs no cargoxx-dep special case.
1.8 Phasing
| Phase | Deliverable |
|---|---|
| 1a | install rules + Config.cmake + .pc; cmake --install in buildCppPackage |
| 1b | { path = "..." } deps end-to-end (manifest parse + lock + recursive buildCppPackage); e2e fixture |
| 1c | { git = "...", rev = "..." } deps via pkgs.fetchgit |
| 1d | { version = "1.0", registry = "cargoxx" } deps (gated on Phase 2 being live) |
Part 2 — Gitea-hosted registry
2.1 cargoxx-pkgs repo layout
cargoxx-pkgs/
├── flake.nix
├── recipes/
│ └── <name>/
│ ├── maintainers.txt # gitea usernames, one per line
│ ├── meta.toml # description, homepage, license, repo
│ └── versions/
│ ├── 1.0.0.toml
│ └── 1.0.1.toml
└── .gitea/workflows/
├── validate-pr.yml
└── auto-merge.yml
2.2 Recipe versions/<v>.toml
schema = 1
name = "mylib"
version = "1.0.0"
[source]
type = "git"
url = "https://gitea/me/mylib"
commit = "abc123…" # 40-char
sha256 = "sha256-…" # FOD pin
[dependencies]
fmt = "10.2"
otherlib = { version = "0.3", registry = "cargoxx" }
[lock]
nixpkgs_rev = "…"
flake_utils_rev = "…"
cargoxx_rev = "…"
[meta]
description = "…"
homepage = "https://…"
license = "MIT"
2.3 cargoxx-pkgs flake (the substitution surface)
{
description = "cargoxx package registry";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/<rev>";
inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.cargoxx.url = "git+https://gitea/.../cargoxx?rev=<rev>";
outputs = { self, nixpkgs, flake-utils, cargoxx }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
mkPackage = recipeFile:
let r = builtins.fromTOML (builtins.readFile recipeFile);
in cargoxx.lib.${system}.buildCppPackage {
src = pkgs.fetchgit {
inherit (r.source) url;
rev = r.source.commit;
hash = r.source.sha256;
};
};
# Enumerate recipes/<name>/versions/<v>.toml, build packagesAttrs.
packagesAttrs = …;
in {
packages = packagesAttrs;
});
}
CI evaluates this flake, builds the new attr, pushes $out to the
binary cache. Consumers later get a cache hit for the SAME
derivation hash via builtins.getFlake at the same registry_rev.
2.4 Publishing flow (cargoxx publish)
cargoxx publish [--dry-run]
1. Validate Cargoxx.toml has name, version, description, license, repository.
2. Validate working tree clean; current commit on a remote branch.
3. Validate Cargoxx.lock pins every cargoxx-source dep to a registry version
(path = "..." deps disallowed in publish).
4. Compute recipe TOML:
[source].url = git remote URL
[source].commit = HEAD commit
[source].sha256 = sha256 of `git archive HEAD`
[dependencies] = mirror Cargoxx.toml
[lock] = mirror Cargoxx.lock pins
5. Clone cargoxx-pkgs (cached under ~/.cache/cargoxx/registry-clone).
6. Create branch publish/<name>-<version>; new package → also create
recipes/<name>/{maintainers.txt,meta.toml}.
7. Write recipes/<name>/versions/<version>.toml; commit.
8. Push branch; `tea pr create` (tea login is already set up).
2.5 Validation bot (.gitea/workflows/validate-pr.yml)
Runs on every PR via self-hosted runners:
- Schema check: PR touches only
recipes/<name>/**; recipe TOML validates. - Source fixity: re-fetch
[source].urlat[source].commit, recompute sha256, compare. - Build:
nix build .#packages.x86_64-linux.<name>. - Cache push:
nix copy --to "file:///srv/cargoxx-cache?secret-key=$KEY" \ .#packages.x86_64-linux.<name>/srv/cargoxx-cacheserved by nginx as the binary cache. - Dependency closure: every
[dependencies]entry exists at the same PR HEAD. - Maintainer match:
- New package: PR author becomes maintainer (write
maintainers.txt). - Existing package: PR author appears in
maintainers.txt. Else labelneeds-human-review.
- New package: PR author becomes maintainer (write
- On all-green + maintainer-match: label
auto-merge.
auto-merge.yml triggers on the auto-merge label and merges via
the Gitea API.
Maintainer transfer is a separate PR editing maintainers.txt. Same
validation: PR author must already be on the list.
2.6 Resolving registry deps (cargoxx add foo)
cargoxx add foo[@<version>]
1. Fetch https://gitea/.../cargoxx-pkgs/raw/branch/master/index.json
(the index, emitted by CI, maps name → list of versions).
2. Pick the highest matching version (or @<version>).
3. Fetch recipes/foo/versions/<v>.toml.
4. Write Cargoxx.toml: foo = { version = "<v>", registry = "cargoxx" }.
5. Write Cargoxx.lock: source_kind = "registry",
registry_attr = "foo_<safe_ver>".
6. Also persist lock.registry_rev = <current master commit>.
The bundled cargoxx wrapper has git, curl on PATH, so this works
on non-Nix hosts too.
2.7 Binary cache config
Once the cache URL + signing key are decided, the cargoxx wrapper's
NIX_CONFIG (in flake.nix's cargoxxNixConfig) gains:
substituters = https://cache.cargoxx.<gitea-domain>/... https://cache.nixos.org
trusted-public-keys = cache.cargoxx.<gitea-domain>:<base64>= cache.nixos.org:6NCH…=
Set via --set-default, so a user with a different setup can
override by exporting NIX_CONFIG.
2.8 Hosting
| Concern | Choice |
|---|---|
| Repo host | Self-hosted Gitea (your instance). |
| CI | Gitea Actions on self-hosted runners (your existing pool). |
| Binary cache | Static-HTTPS to start (nix copy --to file:///srv/cargoxx-cache); migrate to attic (https://github.com/zhaofengli/attic) if scale demands. |
| Publishing auth | tea login (already configured). |
| Architectures | x86_64-linux only for v1. aarch64/darwin are follow-ups. |
2.9 Phasing
| Phase | Deliverable |
|---|---|
| 2a | cargoxx-pkgs repo skeleton + flake.nix (zero recipes). |
| 2b | .gitea/workflows/validate-pr.yml; manual recipe PRs. |
| 2c | cargoxx publish CLI; auto-merge.yml. |
| 2d | cargoxx add ↔ registry index; CI cache push; wrapper substituters config. |
Module BMI strategy
Following the libstdc++/libc++ model in nixpkgs: ship source .cppm
via install(TARGETS … FILE_SET CXX_MODULES …), ship the compiled
archive (libfoo.a) of impl bits. Consumers regenerate the BMI in
their own build (cmake's FILE_SET CXX_MODULES handles it when
find_package(foo) is called). The compiled archive is what's
cached — the BMI is cheap to regenerate against an already-cached
archive.
cargoxx already enables CMAKE_CXX_MODULE_STD ON, which CMake uses to
rebuild the std BMI in each consumer project. The same machinery
covers cargoxx libraries.
Why buildCppPackage doesn't fetch source for registry deps
The registry flake (cargoxx-pkgs) owns the
source-fetch → cargoxx-build → cmake-install pipeline. Its
packages.<system>.<name> is an input-addressed derivation: same
sources + same toolchain pins → same hash → cache hit.
Consumers reference that derivation via builtins.getFlake at the
locked registry_rev. Nix's binary cache substitutes the $out
directly — no source clone, no compile. Source fetch + build live
inside the registry's eval, run once per recipe at CI time. This is
the same model as nixpkgs/cache.nixos.org: you don't fetch fmt's
source to use it from nixpkgs.
Open follow-ups
- Gitea domain + cache URL (for wrapper config in Phase 2d).
- Signing key generation
(
nix-store --generate-binary-cache-key cache.cargoxx.<domain> <secret> <public>); private into runner secrets, public into wrapper config. - Multi-arch (
aarch64-linux,*-darwin) — Phase 3+. - Conan-style wrap recipes for non-cargoxx libraries — design now, defer implementation.