Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/test-rust-workspace-cache.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ jobs:
with:
persist-credentials: false

- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: "0.13.1"
enable-cache: false

- name: Test pruning and lifecycle
shell: bash
run: |
Expand All @@ -41,6 +47,7 @@ jobs:
uses: $/rust-workspace-cache
with:
workspace: ${{ steps.fixture.outputs.workspace }}
cache-bin: ${{ runner.os != 'macOS' }}
prefix-key: test-rust-workspace-cache
key: ${{ github.run_id }}-${{ github.run_attempt }}

Expand Down
13 changes: 10 additions & 3 deletions rust-workspace-cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@ by the build. Pruning leaves dependencies and unused workspace packages untouche
RUSTC_BOOTSTRAP: "1"
```

Install Rust before this action. Python 3.11 or newer must be available as
`python3` on Linux and macOS, or `python` on Windows, when the job ends. Pruning
also requires `cargo` and a current `Cargo.lock` in the workspace at that point.
Install Rust before this action. Pruning uses `uv` from `PATH` when available,
requesting Python 3.11 or newer without discovering the caller's Python project or
`uv` configuration files. `uv` may download Python if no compatible interpreter is
available. The action does not install `uv`.

If `uv` is not found, Python 3.11 or newer must be available as `python3` on
Linux and macOS, or `python` on Windows, when the job ends. Pruning also requires
`cargo` and a current `Cargo.lock` in the workspace at that point. A failure from
`uv` fails pruning; it does not retry with Python.

The action sets `CARGO_UNSTABLE_MTIME_ON_USE` to `true` for subsequent steps when
`save-if` is `true` and no exact cache match was restored; otherwise it sets the
Expand All @@ -41,6 +47,7 @@ evidence that Cargo tracked artifact usage.
| `prefix-key` | `v1-rust-workspace` | Cache key prefix. |
| `key` | Empty | Additional cache key. |
| `add-job-id-key` | `true` | Include the job ID in the cache key; set to `false` to share entries across jobs. |
| `cache-bin` | `true` | Cache binaries installed with Cargo. |
| `save-if` | `true` | Whether to save a new cache entry after a successful job. |

`workspace` and `target-directory` configure the cached paths. The build command
Expand Down
4 changes: 4 additions & 0 deletions rust-workspace-cache/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ inputs:
add-job-id-key:
description: Include the job ID in the cache key.
default: "true"
cache-bin:
description: Cache binaries installed with Cargo.
default: "true"
save-if:
description: Save the cache after a successful job when this is true.
default: "true"
Expand All @@ -41,6 +44,7 @@ runs:
workspaces: ${{ inputs.workspace }} -> ${{ inputs.target-directory }}
cache-workspace-crates: "true"
add-job-id-key: ${{ inputs.add-job-id-key }}
cache-bin: ${{ inputs.cache-bin }}
save-if: ${{ inputs.save-if }}

# Post steps run in reverse order, so pruning finishes before the cache is saved.
Expand Down
95 changes: 95 additions & 0 deletions rust-workspace-cache/prune/lifecycle.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { test } = require("node:test");
const vm = require("node:vm");

function setup(context, enabled) {
const directory = fs.mkdtempSync(
Expand Down Expand Up @@ -74,3 +75,97 @@ test("the post step does nothing when no pruning state was recorded", () => {
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, "");
});

function post(context, results, platform = process.platform) {
const { state: recorded } = setup(context, true);
const state = JSON.parse(recorded.slice("cache=".length));
const calls = [];
const childProcess = {
spawnSync(command, args, options) {
calls.push({ command, args: Array.from(args), options: { ...options } });
return results.shift();
},
};
const child = { env: { STATE_cache: JSON.stringify(state) }, platform };
try {
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, "post.cjs"), "utf8"),
{
__dirname,
process: child,
require: (name) =>
name === "node:child_process" ? childProcess : require(name),
},
);
} finally {
assert.ok(!fs.existsSync(path.dirname(state.marker)));
}
return { state, calls, exitCode: child.exitCode };
}

test("prefers uv without discovering the caller's Python project or configuration", (context) => {
const result = post(context, [{ status: 0 }]);
assert.equal(result.exitCode, 0);
assert.deepEqual(result.calls, [
{
command: "uv",
args: [
"run",
"--no-project",
"--no-config",
"--python",
">=3.11",
path.join(__dirname, "prune.py"),
result.state.profile,
result.state.marker,
],
options: { cwd: result.state.workspace, stdio: "inherit" },
},
]);
});

for (const [platform, python] of [
["linux", "python3"],
["darwin", "python3"],
["win32", "python"],
]) {
test(`falls back to ${python} on ${platform} when uv is missing`, (context) => {
const missing = Object.assign(new Error("uv is missing"), {
code: "ENOENT",
});
const result = post(context, [{ error: missing }, { status: 0 }], platform);
assert.equal(result.exitCode, 0);
assert.deepEqual(
result.calls.map((call) => call.command),
["uv", python],
);
assert.deepEqual(result.calls[1].args, [
path.join(__dirname, "prune.py"),
result.state.profile,
result.state.marker,
]);
assert.deepEqual(result.calls[1].options, {
cwd: result.state.workspace,
stdio: "inherit",
});
});
}

test("does not run pruning again when uv reports a failure", (context) => {
const result = post(context, [{ status: 7 }]);
assert.equal(result.exitCode, 7);
assert.deepEqual(
result.calls.map((call) => call.command),
["uv"],
);
});

test("does not fall back when uv cannot be executed", (context) => {
const denied = Object.assign(new Error("uv is not executable"), {
code: "EACCES",
});
assert.throws(
() => post(context, [{ error: denied }]),
/uv is not executable/,
);
});
17 changes: 13 additions & 4 deletions rust-workspace-cache/prune/post.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@ const { spawnSync } = require("node:child_process");
if (process.env.STATE_cache) {
const { workspace, profile, marker } = JSON.parse(process.env.STATE_cache);
try {
const result = spawnSync(
process.platform === "win32" ? "python" : "python3",
[path.join(__dirname, "prune.py"), profile, marker],
{ cwd: workspace, stdio: "inherit" },
const args = [path.join(__dirname, "prune.py"), profile, marker];
const options = { cwd: workspace, stdio: "inherit" };
let result = spawnSync(
"uv",
["run", "--no-project", "--no-config", "--python", ">=3.11", ...args],
options,
);
if (result.error?.code === "ENOENT") {
result = spawnSync(
process.platform === "win32" ? "python" : "python3",
args,
options,
);
}
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
} finally {
Expand Down
6 changes: 6 additions & 0 deletions rust-workspace-cache/tests/smoke/setup.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ fs.writeFileSync(
path.join(workspace, "src", "lib.rs"),
"pub fn example() {}\n",
);
// Pruning must skip discovery of the caller's Python project and uv configuration.
fs.writeFileSync(
path.join(workspace, "pyproject.toml"),
'[project]\nname = "workspace-cache-fixture"\nversion = "0.0.0"\nrequires-python = "==3.7.*"\ndependencies = []\n\n[tool.uv]\nrequired-version = "==0.0.1"\n',
);
fs.writeFileSync(path.join(workspace, ".python-version"), "3.7\n");
const result = spawnSync("cargo", ["generate-lockfile", "--offline"], {
cwd: workspace,
stdio: "inherit",
Expand Down
Loading