fix: include a virtual workspace's root Cargo.toml in the cache key (#395)

In a virtual workspace the root `Cargo.toml` is not a package, so the cache key hashed only the
members' manifests and left out the root's `[profile]`, `[patch]` and `[workspace.dependencies]`
(#268). Editing one of those, such as a `[profile.dev]` opt-level, kept the same key: every run
restored the stale cache as an exact hit, rebuilt what the edit invalidated, and saved nothing, so
the rebuild repeated on every run until something else changed the key.

This change hashes the root manifest of the workspace `cargo metadata` reports, along with its
members' manifests. It ignores `workspace.package.version` and the paths and versions of path
dependencies in `[workspace.dependencies]`, as it already does for members, so a release version
bump still keeps the key. It also reads `Cargo.lock` from that root, so a `workspaces` entry that
points at a member directory now hashes the lockfile that builds it. A workspace whose root is a
package keeps its key, and a virtual workspace gets a new one once.
This commit is contained in:
Nathan Stocks
2026-10-23 09:58:52 +02:00
committed by GitHub
parent d1527c8f67
commit c8037a86dd
9 changed files with 67 additions and 35 deletions
+1 -1
View File
@@ -147,7 +147,7 @@ This cache is automatically keyed by:
available),
- the following values, if `add-rust-environment-hash-key` is `"true"`:
- the value of some compiler-specific environment variables (eg. RUSTFLAGS, etc), and
- a hash of all `Cargo.lock` / `Cargo.toml` files found anywhere in the repository (if present).
- a hash of the workspace's `Cargo.lock` and of the `Cargo.toml` of the workspace root and every workspace member (if present).
- a hash of all `rust-toolchain` / `rust-toolchain.toml` files in the root of the repository (if present).
- a hash of all `.cargo/config.toml` files in the root of the repository (if present).
+1 -1
View File
@@ -1,4 +1,4 @@
import { x as commonjsGlobal, y as requireTunnel, z as getDefaultExportFromCjs, A as getAugmentedNamespace } from './cleanup-e3M0pm4f.js';
import { x as commonjsGlobal, y as requireTunnel, z as getDefaultExportFromCjs, A as getAugmentedNamespace } from './cleanup-DYJDrBaf.js';
import os__default from 'os';
import crypto__default from 'crypto';
import fs__default from 'fs';
+1 -1
View File
@@ -1,4 +1,4 @@
import { f as debug, m as mkdirP, n as create, l as exec, w as which, o as warning, i as info, H as HttpCodes, p as HttpClientError, q as HttpClient, t as isDebug, u as setSecret, B as BearerCredentialHandler, v as rmRF, e as error } from './cleanup-e3M0pm4f.js';
import { f as debug, m as mkdirP, n as create, l as exec, w as which, o as warning, i as info, H as HttpCodes, p as HttpClientError, q as HttpClient, t as isDebug, u as setSecret, B as BearerCredentialHandler, v as rmRF, e as error } from './cleanup-DYJDrBaf.js';
import * as path from 'path';
import * as crypto from 'crypto';
import * as fs from 'fs';
+28 -13
View File
@@ -34972,10 +34972,10 @@ async function getCacheProvider() {
let cache;
switch (cacheProvider) {
case "github":
cache = await import('./cache-Dl81DHPs.js');
cache = await import('./cache-DoD2J4WY.js');
break;
case "warpbuild":
cache = await import('./cache-BzPKClMA.js').then(function (n) { return n.c; });
cache = await import('./cache-BaeW9gVW.js').then(function (n) { return n.c; });
break;
default:
throw new Error(`The \`cache-provider\` \`${cacheProvider}\` is not valid.`);
@@ -35006,6 +35006,7 @@ class Workspace {
async getPackages(cmdFormat, filter, extraArgs) {
const cmd = "cargo metadata --all-features --format-version 1" + (extraArgs ? ` ${extraArgs}` : "");
let packages = [];
let workspaceRoot = this.root;
try {
debug(`collecting metadata for "${this.root}"`);
const meta = JSON.parse(await getCmdOutput(cmdFormat, cmd, {
@@ -35013,6 +35014,7 @@ class Workspace {
env: { ...process.env, CARGO_ENCODED_RUSTFLAGS: "" },
}));
debug(`workspace "${this.root}" has ${meta.packages.length} packages`);
workspaceRoot = meta.workspace_root;
for (const pkg of meta.packages.filter(filter)) {
const targets = pkg.targets.filter((t) => t.kind.some((kind) => SAVE_TARGETS.has(kind))).map((t) => t.name);
packages.push({ name: pkg.name, version: pkg.version, targets, path: path__default.dirname(pkg.manifest_path) });
@@ -35021,13 +35023,16 @@ class Workspace {
catch (err) {
console.error(err);
}
return packages;
return { packages, workspaceRoot };
}
async getPackagesOutsideWorkspaceRoot(cmdFormat) {
return await this.getPackages(cmdFormat, (pkg) => !pkg.manifest_path.startsWith(this.root));
const filter = (pkg) => !pkg.manifest_path.startsWith(this.root);
return (await this.getPackages(cmdFormat, filter)).packages;
}
/** The workspace's members, and the root directory of the cargo workspace they belong to. */
async getWorkspaceMembers(cmdFormat) {
return await this.getPackages(cmdFormat, (_) => true, "--no-deps");
const { packages, workspaceRoot } = await this.getPackages(cmdFormat, (_) => true, "--no-deps");
return { members: packages, workspaceRoot };
}
}
@@ -35155,9 +35160,15 @@ class CacheConfig {
for (const workspace of workspaces) {
const root = workspace.root;
keyFiles.push(...(await globFiles(`${root}/**/.cargo/config.toml\n${root}/**/rust-toolchain\n${root}/**/rust-toolchain.toml`)));
const workspaceMembers = await workspace.getWorkspaceMembers(cmdFormat);
const cargo_manifests = sort_and_uniq(workspaceMembers.map((member) => path__default.join(member.path, "Cargo.toml")));
for (const cargo_manifest of cargo_manifests) {
const { members, workspaceRoot } = await workspace.getWorkspaceMembers(cmdFormat);
const cargo_manifests = members.map((member) => path__default.join(member.path, "Cargo.toml"));
// A virtual workspace's root manifest belongs to no member, yet its `[profile]` and `[patch]`
// sections apply to every member.
const root_manifest = path__default.join(workspaceRoot, "Cargo.toml");
if (await exists(root_manifest)) {
cargo_manifests.push(root_manifest);
}
for (const cargo_manifest of sort_and_uniq(cargo_manifests)) {
try {
const content = await fs$1.readFile(cargo_manifest, { encoding: "utf8" });
// Use any since TomlPrimitive is not exposed
@@ -35168,12 +35179,16 @@ class CacheConfig {
pack["version"] = "0.0.0";
}
}
for (const prefix of ["", "build-", "dev-"]) {
const section_name = `${prefix}dependencies`;
if (!(section_name in parsed)) {
const workspace_table = parsed.workspace ?? {};
if ("package" in workspace_table && "version" in workspace_table.package) {
workspace_table.package["version"] = "0.0.0";
}
const dependency_tables = ["", "build-", "dev-"].map((prefix) => parsed[`${prefix}dependencies`]);
dependency_tables.push(workspace_table.dependencies);
for (const deps of dependency_tables) {
if (deps === undefined) {
continue;
}
const deps = parsed[section_name];
for (const key of Object.keys(deps)) {
const dep = deps[key];
try {
@@ -35198,7 +35213,7 @@ class CacheConfig {
keyFiles.push(cargo_manifest);
}
}
const cargo_lock = path__default.join(workspace.root, "Cargo.lock");
const cargo_lock = path__default.join(workspaceRoot, "Cargo.lock");
if (await exists(cargo_lock)) {
try {
const content = await fs$1.readFile(cargo_lock, { encoding: "utf8" });
+1 -1
View File
@@ -1,4 +1,4 @@
import { e as error, g as getCacheProvider, a as getInput, b as exportVariable, C as CacheConfig, i as info, c as cleanTargetDir, r as reportError, s as setOutput } from './cleanup-e3M0pm4f.js';
import { e as error, g as getCacheProvider, a as getInput, b as exportVariable, C as CacheConfig, i as info, c as cleanTargetDir, r as reportError, s as setOutput } from './cleanup-DYJDrBaf.js';
import 'os';
import 'crypto';
import 'fs';
+3 -3
View File
@@ -1,4 +1,4 @@
import { e as error, g as getCacheProvider, a as getInput, d as isCacheUpToDate, i as info, C as CacheConfig, c as cleanTargetDir, f as debug, h as cleanRegistry, j as cleanBin, k as cleanGit, r as reportError, l as exec } from './cleanup-e3M0pm4f.js';
import { e as error, g as getCacheProvider, a as getInput, d as isCacheUpToDate, i as info, C as CacheConfig, c as cleanTargetDir, f as debug, h as cleanRegistry, j as cleanBin, k as cleanGit, r as reportError, l as exec } from './cleanup-DYJDrBaf.js';
import 'os';
import 'crypto';
import 'fs';
@@ -63,8 +63,8 @@ async function run() {
for (const workspace of config.workspaces) {
const packages = await workspace.getPackagesOutsideWorkspaceRoot(config.cmdFormat);
if (workspaceCrates === "true") {
const wsMembers = await workspace.getWorkspaceMembers(config.cmdFormat);
packages.push(...wsMembers);
const { members } = await workspace.getWorkspaceMembers(config.cmdFormat);
packages.push(...members);
}
allPackages.push(...packages);
try {
+19 -8
View File
@@ -167,11 +167,17 @@ export class CacheConfig {
)),
);
const workspaceMembers = await workspace.getWorkspaceMembers(cmdFormat);
const { members, workspaceRoot } = await workspace.getWorkspaceMembers(cmdFormat);
const cargo_manifests = sort_and_uniq(workspaceMembers.map((member) => path.join(member.path, "Cargo.toml")));
const cargo_manifests = members.map((member) => path.join(member.path, "Cargo.toml"));
// A virtual workspace's root manifest belongs to no member, yet its `[profile]` and `[patch]`
// sections apply to every member.
const root_manifest = path.join(workspaceRoot, "Cargo.toml");
if (await exists(root_manifest)) {
cargo_manifests.push(root_manifest);
}
for (const cargo_manifest of cargo_manifests) {
for (const cargo_manifest of sort_and_uniq(cargo_manifests)) {
try {
const content = await fs.readFile(cargo_manifest, { encoding: "utf8" });
// Use any since TomlPrimitive is not exposed
@@ -184,12 +190,17 @@ export class CacheConfig {
}
}
for (const prefix of ["", "build-", "dev-"]) {
const section_name = `${prefix}dependencies`;
if (!(section_name in parsed)) {
const workspace_table = parsed.workspace ?? {};
if ("package" in workspace_table && "version" in workspace_table.package) {
workspace_table.package["version"] = "0.0.0";
}
const dependency_tables = ["", "build-", "dev-"].map((prefix) => parsed[`${prefix}dependencies`]);
dependency_tables.push(workspace_table.dependencies);
for (const deps of dependency_tables) {
if (deps === undefined) {
continue;
}
const deps = parsed[section_name];
for (const key of Object.keys(deps)) {
const dep = deps[key];
@@ -217,7 +228,7 @@ export class CacheConfig {
}
}
const cargo_lock = path.join(workspace.root, "Cargo.lock");
const cargo_lock = path.join(workspaceRoot, "Cargo.lock");
if (await exists(cargo_lock)) {
try {
const content = await fs.readFile(cargo_lock, { encoding: "utf8" });
+2 -2
View File
@@ -41,8 +41,8 @@ async function run() {
for (const workspace of config.workspaces) {
const packages = await workspace.getPackagesOutsideWorkspaceRoot(config.cmdFormat);
if (workspaceCrates === "true") {
const wsMembers = await workspace.getWorkspaceMembers(config.cmdFormat);
packages.push(...wsMembers);
const { members } = await workspace.getWorkspaceMembers(config.cmdFormat);
packages.push(...members);
}
allPackages.push(...packages);
try {
+11 -5
View File
@@ -15,9 +15,10 @@ export class Workspace {
cmdFormat: string,
filter: (p: Meta["packages"][0]) => boolean,
extraArgs?: string,
): Promise<Packages> {
): Promise<{ packages: Packages; workspaceRoot: string }> {
const cmd = "cargo metadata --all-features --format-version 1" + (extraArgs ? ` ${extraArgs}` : "");
let packages: Packages = [];
let workspaceRoot = this.root;
try {
core.debug(`collecting metadata for "${this.root}"`);
const meta: Meta = JSON.parse(
@@ -27,6 +28,7 @@ export class Workspace {
}),
);
core.debug(`workspace "${this.root}" has ${meta.packages.length} packages`);
workspaceRoot = meta.workspace_root;
for (const pkg of meta.packages.filter(filter)) {
const targets = pkg.targets.filter((t) => t.kind.some((kind) => SAVE_TARGETS.has(kind))).map((t) => t.name);
packages.push({ name: pkg.name, version: pkg.version, targets, path: path.dirname(pkg.manifest_path) });
@@ -34,15 +36,18 @@ export class Workspace {
} catch (err) {
console.error(err);
}
return packages;
return { packages, workspaceRoot };
}
public async getPackagesOutsideWorkspaceRoot(cmdFormat: string): Promise<Packages> {
return await this.getPackages(cmdFormat, (pkg) => !pkg.manifest_path.startsWith(this.root));
const filter = (pkg: Meta["packages"][0]) => !pkg.manifest_path.startsWith(this.root);
return (await this.getPackages(cmdFormat, filter)).packages;
}
public async getWorkspaceMembers(cmdFormat: string): Promise<Packages> {
return await this.getPackages(cmdFormat, (_) => true, "--no-deps");
/** The workspace's members, and the root directory of the cargo workspace they belong to. */
public async getWorkspaceMembers(cmdFormat: string): Promise<{ members: Packages; workspaceRoot: string }> {
const { packages, workspaceRoot } = await this.getPackages(cmdFormat, (_) => true, "--no-deps");
return { members: packages, workspaceRoot };
}
}
@@ -56,6 +61,7 @@ export interface PackageDefinition {
export type Packages = Array<PackageDefinition>;
interface Meta {
workspace_root: string;
packages: Array<{
name: string;
version: string;