Skip to content

Ability to normalize workspace member packages' versions for dependency derivation#974

Open
vypxl wants to merge 7 commits intoipetkov:masterfrom
vypxl:ignore-version-in-deps
Open

Ability to normalize workspace member packages' versions for dependency derivation#974
vypxl wants to merge 7 commits intoipetkov:masterfrom
vypxl:ignore-version-in-deps

Conversation

@vypxl
Copy link
Contributor

@vypxl vypxl commented Feb 9, 2026

Motivation

Resolves #445

I've come across the same problem as described in the mentioned issue, and for us, it is causing many wasted CI build minutes. That's why I set out to fix it.

This adds a new flag 'doStripVersion', which can be used to set the versions of workspace crates to a placeholder value. Doing this in the Cargo.lock and Cargo.toml's means we can reuse the dependency cache when we bump versions of our workspace crates.

Checklist

  • added tests to verify new behavior
  • added an example template or updated an existing one
  • updated docs/API.md (or general documentation) with changes
  • updated CHANGELOG.md

@vypxl vypxl force-pushed the ignore-version-in-deps branch from 50c71f5 to e2ed5c4 Compare February 9, 2026 16:10
@vypxl vypxl force-pushed the ignore-version-in-deps branch from e2ed5c4 to 03d96f9 Compare February 9, 2026 16:26
@vypxl vypxl marked this pull request as ready for review February 9, 2026 16:28
@ipetkov
Copy link
Owner

ipetkov commented Feb 11, 2026

Thanks for the PR @vypxl!

The problem I ran into the last time I looked into this is the fact that cargo will refuse to reuse artifacts from two different versions of the same crate, making the version cleanup moot. With published crates this obviously makes sense (two different versions most likely have different sources) but I suspect cargo handles even workspace-members in the same way.

The real key here is that we add a compilesFresh test with this option enabled. If that passes great! But if not, the cache invalidation would make this effort moot

@vypxl
Copy link
Contributor Author

vypxl commented Feb 16, 2026

I can try implementing such a test, but I'll need some more explanation. What should this test verify, specifically?

@ipetkov
Copy link
Owner

ipetkov commented Feb 16, 2026

Search around in the test suite for existing examples of compilesFresh being used

Basically this checks that given a specific workspace and specific build flags, we can chain vendoring -> buildDepsOnly -> cargoBuild and successfully reuse the cached artifacts (namely by checking that we only rebuild the source crates themselves)

@vypxl
Copy link
Contributor Author

vypxl commented Feb 17, 2026

I've looked into this now. I added two tests, one that checks this for the 'simple' project, and one for the 'strip-version-test' project. The simple test passes, so there the byteorder dependency is cached properly.

In the other one, there's a dev-dependency. The test fails here, because the check command that buildDepsOnly caches uses '--all-targets', while the compilesFresh test checks plain cargo check. If I add that flag to the test definition checks/compilesFresh.nix#L52, the test passes.

What should we do here?

P.S. In our production repo, I observed that the caching behavior with this change is.. inconsistent. It doesn't rebuild all dependencies, but a subset is rebuilt atm. Maybe a different configuration is needed there to match the cargo flags, similar to the test.

@vypxl
Copy link
Contributor Author

vypxl commented Feb 20, 2026

I was able to modify our production flake to fix the weird partial caching, it was a mismatch of env variables and flags, together with missing feature unification. Now the dependencies are being reused fully, with the version stripping enabled.

Copy link
Owner

@ipetkov ipetkov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to modify our production flake to fix the weird partial caching, it was a mismatch of env variables and flags, together with missing feature unification. Now the dependencies are being reused fully, with the version stripping enabled.

Glad you sorted it out, this is exactly what I was going to suggest. Generally you want the same build flags/features/env vars to match between buildDepsOnly and any other derivations chained on top, at the very least to the extent that it affects feature unification and other cache-busting aspects

serde_json = "1.0"

[dev-dependencies]
tokio-test = "0.4"
Copy link
Owner

@ipetkov ipetkov Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't actually seem to be used, can we replace it with a "lighter" dependency? E.g. a lot of the test cases use crates like libc and byteorder since they have no other transitive deps. If we just need to check that dev-dependencies are still being cached it shouldn't matter what they are (though the smaller we keep these the ligher our test suite is to run. We're already hitting some disk limits in the github actions runners)

cargoExtraArgs ? "--locked",
cargoTestCommand ? "cargoWithProfile test",
cargoTestExtraArgs ? "",
doStripVersion ? false,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Nix adding a foo ? default binding doesn't actually "insert" it in the args binding, its only visible in the current scope. Meaning this doesn't actually do anything as currently defined since we don't access doStripVersion anywhere in this file

Though if this is being handled by other helpers that's totally fine, we can omit it here

crateName = crateNameFromCargoToml argsMaybeDummySrcOverride;

# Use placeholder version when stripping versions to improve cache hit rates
versionPlaceholder = "0.0.0";
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on making the placeholder string something explicit like "version-placeholder" or even just a blank string?

It might be confusing to folks seeing logs and derivations with version 0.0.0 instead of their actual version, but seeing something named like *placeholder* might be a better clue

As currently written this functionality is opt-in (so using 0.0.0 is reasonable!) but if we ever decided to make this on-by-default the placeholder may matter slightly more

Comment on lines +56 to +57
effectiveVersion =
if doStripVersion then versionPlaceholder else (args.version or crateName.version);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of explicitly handling this here (and in all other callers which set version), thoughts about extending crateNameFromCargoToml to understand if args.doStripVersion is set and have it inject the placeholder? Might simplify things so callers don't need to juggle it themselves

// {
CARGO_BUILD_TARGET = args.CARGO_BUILD_TARGET or "wasm32-unknown-unknown";
doCheck = args.doCheck or false;
inherit doStripVersion;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably don't need to forward this here explicitly, we're already passing args along, so if doStripVersion is set, we'll observe it at the lower levels (i.e. we can probably revert all the changes in this file)

Comment on lines 125 to 126
{ inherit cargoLockContents; }
{ inherit cargoLockParsed; }
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of these specific cases now fail to evaluate with error: cannot coerce null to a string: null

Comment on lines +6 to +13
{
cargoToml ? throw "either cargoToml or cargoTomlContents must be specified",
cargoTomlContents ? builtins.readFile cargoToml,
# ([String] -> Boolean)
cleanCargoTomlFilter ? filters.cargoTomlDefault,
doStripVersion ? false,
versionPlaceholder ? "0.0.0",
}:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, why did this get moved? It was intentionally kept lower down the file as everything up to the filterData declaration does not depend on the actual inputs to cleanCargoToml (i.e. Nix can optimize not needing to re-evaluate all that on each invocation)

if we need a new let ... in ... binding for cleaned we can easily add it in below!

Comment on lines +42 to +50
if doStripVersion && builtins.hasAttr "package" cleaned then
cleaned
// {
package = cleaned.package // {
version = versionPlaceholder;
};
}
else
cleaned
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can probably be simplified with a mix of lib.recursiveUpdate and lib.optionalAttrs. Something like:

lib.recursiveUpdate cleaned (lib.optionalAttrs (doStripVersion && cleaned ? package) {
  package.version = versionPlaceholder;
})

Comment on lines +349 to +351
cleanedCargoLock = writeTOML "Cargo.lock" (
if doStripVersion then cleanWorkspacePackageVersions cargoLockContents else cargoLockContents
);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If doStripVersion is disabled this will still end up writing a Cargo.lock to the Nix store and copying from it. Can we optimize this to fall back to whatever the original value was

copyCargoLock = if cargoLock == null then "" else "cp ${cargoLock} $out/Cargo.lock";
copyCargoLock =
if cargoLock == null && !builtins.pathExists (origSrc + "/Cargo.lock") then
null
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably what's causing the evaluation failure above. If there's no cargoLock file we should be interpolating an empty string (i.e. nothing to do) rather than a null here.

In general you can run the test suite via nix flake check ./test# (tests are defined in a sub flake for reasons beyond this PR), or you can use ./test.sh which will run the full, comprehensive test suite

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

unnecessary rebuild of unrelated packages in monorepo on member version bump

2 participants