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
49 changes: 49 additions & 0 deletions .github/workflows/test-rust-workspace-cache.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Test Rust workspace cache

on:
pull_request:
paths:
- rust-workspace-cache/**
- .github/workflows/test-rust-workspace-cache.yml
workflow_dispatch:

permissions:
contents: read

env:
RUSTC_BOOTSTRAP: "1"

jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Test pruning and lifecycle
shell: bash
run: |
python -m unittest discover -s rust-workspace-cache/prune -p 'test_*.py'
node --test rust-workspace-cache/prune/lifecycle.test.cjs

# Register this first so its post step checks the result after the cache post steps.
- name: Create workspace fixture
id: fixture
uses: $/rust-workspace-cache/tests/smoke

- name: Cache workspace
uses: $/rust-workspace-cache
with:
workspace: ${{ steps.fixture.outputs.workspace }}
prefix-key: test-rust-workspace-cache
key: ${{ github.run_id }}-${{ github.run_attempt }}

- name: Build workspace
working-directory: ${{ steps.fixture.outputs.workspace }}
run: cargo build --locked --offline -Z checksum-freshness
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Shared GitHub Actions for Astral projects.

- [Rust workspace cache](rust-workspace-cache/): cache workspace crates and prune
superseded artifacts before saving.
- [Create release pull request](create-release-pr/): commit prepared release files
and create or update a pull request.
- [Release smoke test](release-smoke-test/): test release artifacts in disposable containers.
Expand Down
70 changes: 70 additions & 0 deletions rust-workspace-cache/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Rust workspace cache

Cache Cargo dependencies and workspace crates with `Swatinem/rust-cache`. Before
saving a new cache entry, remove superseded artifacts for workspace packages used
by the build. Pruning leaves dependencies and unused workspace packages untouched.

```yaml
- uses: astral-sh/github-actions/rust-workspace-cache@<commit-sha>
with:
key: ${{ hashFiles('Cargo.toml') }}
profile: fast-build-nightly
save-if: ${{ inputs.save-rust-cache == 'true' }}

- run: cargo test --locked --profile fast-build-nightly -Z checksum-freshness
env:
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.

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
variable to `false`. Usage tracking requires nightly Cargo or `RUSTC_BOOTSTRAP=1`.
Use `-Z checksum-freshness` to reuse workspace artifacts across fresh checkouts.
The action does not set `RUSTC_BOOTSTRAP` or choose a Rust toolchain.

Pruning runs after a successful job, before `Swatinem/rust-cache` saves the cache.
Exact cache hits and `save-if: "false"` skip pruning. Pruning fails if no active
Cargo fingerprints are found, preventing a new cache from being saved without
evidence that Cargo tracked artifact usage.

## Inputs

| Input | Default | Description |
| ------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------- |
| `workspace` | `.` | Cargo workspace directory, relative to the checkout or absolute. |
| `target-directory` | `target` | Cargo target directory, relative to `workspace`. |
| `profile` | `debug` | Profile directory within `target-directory`, such as `fast-build-nightly` or `x86_64-unknown-linux-gnu/debug`. |
| `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. |
| `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
must use the same workspace and target directory. `profile` selects the directory
to prune; other profile directories are left unchanged.

With `add-job-id-key: "false"`, jobs can share entries when the remaining cache key
components and GitHub cache scope also match.

The `cache-hit` output reports whether an exact cache match was restored.

## Windows workspaces

An absolute `workspace` supports builds outside the checkout, including a Dev Drive:

```yaml
- uses: astral-sh/github-actions/rust-workspace-cache@<commit-sha>
with:
workspace: ${{ env.UV_WORKSPACE }}
profile: fast-build-nightly
key: ${{ hashFiles('Cargo.toml') }}
add-job-id-key: "false"
save-if: ${{ inputs.save-rust-cache == 'true' }}
```

The action uses GitHub.com's `$/` reference to load its pruning helper from the
same commit. GitHub Enterprise Server is not supported.
53 changes: 53 additions & 0 deletions rust-workspace-cache/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Rust workspace cache
description: Cache Cargo dependencies and workspace crates, pruning superseded workspace artifacts before saving.

inputs:
workspace:
description: Cargo workspace directory.
default: "."
target-directory:
description: Cargo target directory, relative to the workspace.
default: "target"
profile:
description: Build profile directory within the target directory.
default: "debug"
prefix-key:
description: Cache key prefix; change it to invalidate existing caches.
default: "v1-rust-workspace"
key:
description: Additional cache key passed to Swatinem/rust-cache.
default: ""
add-job-id-key:
description: Include the job ID in the cache key.
default: "true"
save-if:
description: Save the cache after a successful job when this is true.
default: "true"

outputs:
cache-hit:
description: Whether an exact cache match was restored.
value: ${{ steps.cache.outputs.cache-hit }}

runs:
using: composite
steps:
- name: Restore Rust workspace cache
id: cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
prefix-key: ${{ inputs.prefix-key }}
key: ${{ inputs.key }}
workspaces: ${{ inputs.workspace }} -> ${{ inputs.target-directory }}
cache-workspace-crates: "true"
add-job-id-key: ${{ inputs.add-job-id-key }}
save-if: ${{ inputs.save-if }}

# Post steps run in reverse order, so pruning finishes before the cache is saved.
- name: Track workspace artifacts
uses: $/rust-workspace-cache/prune
with:
workspace: ${{ inputs.workspace }}
target-directory: ${{ inputs.target-directory }}
profile: ${{ inputs.profile }}
enabled: ${{ inputs.save-if == 'true' && steps.cache.outputs.cache-hit != 'true' }}
22 changes: 22 additions & 0 deletions rust-workspace-cache/prune/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Prune Rust workspace cache
description: Track Cargo artifact usage and prune superseded artifacts after a successful job.

inputs:
workspace:
description: Cargo workspace directory.
required: true
target-directory:
description: Cargo target directory, relative to the workspace.
required: true
profile:
description: Build profile directory within the target directory.
required: true
enabled:
description: Whether to track and prune artifacts.
required: true

runs:
using: node24
main: main.cjs
post: post.cjs
post-if: success()
76 changes: 76 additions & 0 deletions rust-workspace-cache/prune/lifecycle.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { test } = require("node:test");

function setup(context, enabled) {
const directory = fs.mkdtempSync(
path.join(os.tmpdir(), "workspace-cache-test-"),
);
context.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const workspace = path.join(directory, "workspace with spaces");
fs.mkdirSync(workspace);
const environment = path.join(directory, "env");
const state = path.join(directory, "state");
fs.writeFileSync(environment, "");
fs.writeFileSync(state, "");
const result = spawnSync(
process.execPath,
[path.join(__dirname, "main.cjs")],
{
cwd: workspace,
env: {
...process.env,
INPUT_ENABLED: String(enabled),
INPUT_WORKSPACE: workspace,
"INPUT_TARGET-DIRECTORY": "build-output",
INPUT_PROFILE: "fast-build-nightly",
GITHUB_ENV: environment,
GITHUB_STATE: state,
RUNNER_TEMP: directory,
},
encoding: "utf8",
},
);
assert.equal(result.status, 0, result.stderr);
return {
workspace,
environment: fs.readFileSync(environment, "utf8"),
state: fs.readFileSync(state, "utf8"),
};
}

test("records an isolated marker and the external workspace for pruning", (context) => {
const result = setup(context, true);
assert.equal(result.environment, "CARGO_UNSTABLE_MTIME_ON_USE=true\n");
const state = JSON.parse(result.state.slice("cache=".length));
assert.equal(state.workspace, result.workspace);
assert.equal(
state.profile,
path.join(result.workspace, "build-output", "fast-build-nightly"),
);
assert.ok(fs.statSync(state.marker).isFile());
const other = JSON.parse(setup(context, true).state.slice("cache=".length));
assert.notEqual(state.marker, other.marker);
});

test("disables usage tracking and pruning when the cache will not be saved", (context) => {
const result = setup(context, false);
assert.equal(result.environment, "CARGO_UNSTABLE_MTIME_ON_USE=false\n");
assert.equal(result.state, "");
});

test("the post step does nothing when no pruning state was recorded", () => {
const result = spawnSync(
process.execPath,
[path.join(__dirname, "post.cjs")],
{
env: { ...process.env, STATE_cache: "", PATH: "" },
encoding: "utf8",
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, "");
});
26 changes: 26 additions & 0 deletions rust-workspace-cache/prune/main.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const fs = require("node:fs");
const path = require("node:path");

const enabled = process.env.INPUT_ENABLED === "true";
fs.appendFileSync(
process.env.GITHUB_ENV,
`CARGO_UNSTABLE_MTIME_ON_USE=${enabled}\n`,
);

if (enabled) {
const workspace = path.resolve(process.env.INPUT_WORKSPACE);
const profile = path.resolve(
workspace,
process.env["INPUT_TARGET-DIRECTORY"],
process.env.INPUT_PROFILE,
);
const directory = fs.mkdtempSync(
path.join(process.env.RUNNER_TEMP, "rust-workspace-cache-"),
);
const marker = path.join(directory, "marker");
fs.writeFileSync(marker, "");
fs.appendFileSync(
process.env.GITHUB_STATE,
`cache=${JSON.stringify({ workspace, profile, marker })}\n`,
);
}
18 changes: 18 additions & 0 deletions rust-workspace-cache/prune/post.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const fs = require("node:fs");
const path = require("node:path");
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" },
);
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
} finally {
fs.rmSync(path.dirname(marker), { recursive: true, force: true });
}
}
Loading
Loading