diff --git a/.github/workflows/test-rust-workspace-cache.yml b/.github/workflows/test-rust-workspace-cache.yml index 9a36192..f9877c1 100644 --- a/.github/workflows/test-rust-workspace-cache.yml +++ b/.github/workflows/test-rust-workspace-cache.yml @@ -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: | @@ -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 }} diff --git a/rust-workspace-cache/README.md b/rust-workspace-cache/README.md index da865a5..9b33636 100644 --- a/rust-workspace-cache/README.md +++ b/rust-workspace-cache/README.md @@ -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 @@ -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 diff --git a/rust-workspace-cache/action.yml b/rust-workspace-cache/action.yml index ea2f3c4..89f1abf 100644 --- a/rust-workspace-cache/action.yml +++ b/rust-workspace-cache/action.yml @@ -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" @@ -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. diff --git a/rust-workspace-cache/prune/lifecycle.test.cjs b/rust-workspace-cache/prune/lifecycle.test.cjs index 6ca80e1..68967e2 100644 --- a/rust-workspace-cache/prune/lifecycle.test.cjs +++ b/rust-workspace-cache/prune/lifecycle.test.cjs @@ -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( @@ -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/, + ); +}); diff --git a/rust-workspace-cache/prune/post.cjs b/rust-workspace-cache/prune/post.cjs index dc87783..e2b338b 100644 --- a/rust-workspace-cache/prune/post.cjs +++ b/rust-workspace-cache/prune/post.cjs @@ -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 { diff --git a/rust-workspace-cache/tests/smoke/setup.cjs b/rust-workspace-cache/tests/smoke/setup.cjs index 4a162a6..497ff58 100644 --- a/rust-workspace-cache/tests/smoke/setup.cjs +++ b/rust-workspace-cache/tests/smoke/setup.cjs @@ -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",