You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I have been testing runs-on/snapshot@v1 for Rust/Cargo CI build-state reuse on RunsOn runners.
The short version: it works, and it is the best result I have found so far for making Cargo behave close to a local no-op rebuild in CI. However, the current action interface appears to be shaped mostly around simple single-path use cases such as Docker layer state. For a Rust workspace with many deployable binaries built in parallel, I had to encode cache identity into version, manually mount into an isolated path, manually implement a checkout that does not destroy the restored state, and choose blunt save policies.
This issue is a request for API additions and documentation examples that would make runs-on/snapshot a cleaner fit for build-state snapshots in matrix CI jobs.
Context: Rust/Cargo CI caching problem
Cargo no-op rebuilds are fast locally because Cargo reuses more than compiled artifacts. It reuses the whole build state and filesystem context:
Traditional actions/cache-style archive caches help with dependency downloads and sometimes compiled artifacts, but they do not reliably reproduce the same filesystem state Cargo uses to decide whether units are fresh.
For Rust workloads, a block-level snapshot is a much better primitive than archive caching because it restores the actual filesystem state instead of reconstructing it from a tar/zstd archive.
flowchart TD
local[Local no-op Cargo rebuild]
state[Same filesystem state]
cargo[Cargo freshness check]
fresh[Fresh units, no rebuild]
state --> cargo --> fresh
local --> state
state --> source[Source tree mtimes]
state --> target[Target fingerprints and dep-info]
state --> scripts[Build script outputs]
state --> registry[Cargo registry source paths]
state --> tools[Toolchain and build flags]
Loading
Repository shape
The workload is a Rust workspace with many workspace crates and many deployable binaries. CI builds each deployable binary in a matrix job using a command shaped like:
After the first snapshot was seeded, subsequent matrix jobs restored build state correctly and cargo lambda build became effectively a no-op for unchanged builds.
I measured a large improvement over the previous setup. A lean version of the workflow with snapshot-local tools was around 36s-41s per matrix job.
The actual Cargo build step itself was only a few seconds, with the remaining time mostly runner setup, snapshot restore/save initiation, and tool setup.
This is an excellent result and clearly better than the S3/archive cache approaches I considered for this specific “preserve Cargo local build state” goal.
Current working workflow shape
This is a simplified version of the working shape:
This works, but it took a lot of workflow plumbing to get there.
sequenceDiagram
participant Job as Matrix job
participant Snapshot as runs-on/snapshot
participant FS as /mnt/rust-cargo-snapshot
participant Git as Manual checkout
participant Cargo as Cargo build
Job->>Snapshot: restore path with per-function identity
Snapshot->>FS: mount restored EBS volume
Job->>FS: create workspace, cargo-home, tool dirs
Job->>Git: fetch requested ref
Git->>FS: checkout only if HEAD differs
Job->>Cargo: build function binary
Cargo->>FS: reuse target fingerprints/artifacts
Job->>FS: remove credentials before save
Snapshot->>FS: unmount and start new snapshot in post step
Loading
Friction points
1. Snapshot identity is overloaded into version
The current inputs are:
path
version
volume_type
volume_iops
volume_throughput
volume_size
volume_initialization_rate
wait_for_completion
save
version appears to be both a schema/version bump and part of the snapshot lookup identity. For a matrix workflow, each matrix entry needs a distinct snapshot stream. Since snapshot lookup does not include path, I had to do this:
However, a more advanced case would be “restore, build, then decide at runtime whether saving is worth it.” For example, skip saving if the restored snapshot already corresponds to this commit, or skip saving if no relevant files changed.
There is no way for a shell step after restore/build to tell the post step “do not save after all.”
I had to inspect action logs manually to answer questions like:
Did this run restore a real snapshot?
Did it fall back to the default branch?
Did it create a blank volume?
Which snapshot id was used?
Did it save successfully?
Outputs would make it easy to add workflow summaries and conditional behavior.
5. Directly mounting over ${{ github.workspace }} is dangerous
The first attempt mounted the snapshot directly at GitHub’s workspace path:
path: ${{ github.workspace }}
This caused runs-on/snapshot post-step save to fail:
umount: /home/runner/_work/.../<repo>: target is busy
The job still completed successfully, but no snapshot was saved. The next run restored a blank volume again.
The working pattern was to mount somewhere else:
/mnt/rust-cargo-snapshot
Then use:
/mnt/rust-cargo-snapshot/workspace
as the actual repository checkout/build directory.
This pattern is not obvious from the docs. It may be worth documenting explicitly:
For source/workspace snapshots, avoid mounting directly on GITHUB_WORKSPACE.
Mount under /mnt/... and perform checkout/build inside that mount.
6. actions/checkout was not ideal for restored workspaces
I also found that actions/checkout can be too aggressive for this use case. The goal is not just to get the right source contents into the workspace. The goal is to update a previously restored workspace in a way that resembles a local git fetch / git checkout without destroying the restored target/ tree or touching unchanged source files unnecessarily.
In the first blank-volume run, actions/checkout printed:
Deleting the contents of '<workspace>'
and then failed during cleanup because the restored mount point was not yet a Git repository:
fatal: --local can only be used inside a git repository
fatal: not a git repository (or any parent up to mount point ...)
Even when clean: false is set, actions/checkout still has bootstrap behavior for an empty/non-repo target directory. That behavior is reasonable for normal ephemeral CI, but it is not ideal when the target directory is a restored build-state volume that should be preserved.
The other important concern is file mtimes. Cargo freshness checks rely heavily on relationships between source mtimes, dep-info files, fingerprint files, build script outputs, and compiled artifacts. If checkout rewrites unchanged source files, Cargo can see source files as newer than restored target metadata and mark units dirty even when file contents are identical.
So the checkout operation for this pattern needs slightly different semantics:
if the restored path is not a repo:
initialize it
fetch the requested ref
if HEAD is already the requested SHA:
do not checkout again
avoid touching files
if HEAD differs:
checkout the requested SHA
do not run git clean
do not delete target/
I ended up writing a manual checkout step that:
Initializes the repo only if .git is missing.
Fetches the requested ref.
Checks out the requested SHA only if HEAD is not already that SHA.
Avoids git clean.
This is important because Cargo freshness depends on unchanged source file mtimes relative to target metadata.
A helper action or documented recipe would be very useful:
Or the snapshot action docs could include a recommended checkout snippet for this pattern.
7. Matrix/workspace use case could use first-class examples
The current examples are mostly simple single-path examples. For a Rust workspace with many matrix builds, the important details are:
one snapshot stream per matrix item
same absolute build path each time
CARGO_HOME inside snapshot if target dep-info references registry source paths
tools inside snapshot if setup time matters
manual checkout that does not clobber target or unchanged files
save policy: main saves, PRs may restore-only
A Rust/Cargo example in the docs would help users avoid a lot of trial and error.
Rust CI performance is often bottlenecked by Cargo rebuilds. Generic archive caches are not enough to reproduce local no-op rebuild behavior. runs-on/snapshot is the first mechanism I tested that actually got close to local behavior for cargo lambda build in CI.
The current action is already powerful enough to make this work, but the YAML is more complex than it needs to be for matrix/workspace build-state snapshots. A few small API additions would make this pattern much easier to adopt and less error-prone.
Summary
I have been testing
runs-on/snapshot@v1for Rust/Cargo CI build-state reuse on RunsOn runners.The short version: it works, and it is the best result I have found so far for making Cargo behave close to a local no-op rebuild in CI. However, the current action interface appears to be shaped mostly around simple single-path use cases such as Docker layer state. For a Rust workspace with many deployable binaries built in parallel, I had to encode cache identity into
version, manually mount into an isolated path, manually implement a checkout that does not destroy the restored state, and choose blunt save policies.This issue is a request for API additions and documentation examples that would make
runs-on/snapshota cleaner fit for build-state snapshots in matrix CI jobs.Context: Rust/Cargo CI caching problem
Cargo no-op rebuilds are fast locally because Cargo reuses more than compiled artifacts. It reuses the whole build state and filesystem context:
Traditional
actions/cache-style archive caches help with dependency downloads and sometimes compiled artifacts, but they do not reliably reproduce the same filesystem state Cargo uses to decide whether units are fresh.For Rust workloads, a block-level snapshot is a much better primitive than archive caching because it restores the actual filesystem state instead of reconstructing it from a tar/zstd archive.
flowchart TD local[Local no-op Cargo rebuild] state[Same filesystem state] cargo[Cargo freshness check] fresh[Fresh units, no rebuild] state --> cargo --> fresh local --> state state --> source[Source tree mtimes] state --> target[Target fingerprints and dep-info] state --> scripts[Build script outputs] state --> registry[Cargo registry source paths] state --> tools[Toolchain and build flags]Repository shape
The workload is a Rust workspace with many workspace crates and many deployable binaries. CI builds each deployable binary in a matrix job using a command shaped like:
Conceptually:
Each matrix entry runs on a separate runner and should have its own build-state snapshot so matrix jobs do not overwrite or race with each other.
flowchart LR workflow[Matrix workflow] fna[function_a job] fnb[function_b job] fnc[function_c job] sna[(snapshot stream A)] snb[(snapshot stream B)] snc[(snapshot stream C)] workflow --> fna --> sna workflow --> fnb --> snb workflow --> fnc --> snc sna -. default branch fallback .-> main[(default branch snapshots)] snb -. default branch fallback .-> main snc -. default branch fallback .-> mainWhat worked well
Using
runs-on/snapshot@v1, I was able to snapshot an isolated filesystem root containing:After the first snapshot was seeded, subsequent matrix jobs restored build state correctly and
cargo lambda buildbecame effectively a no-op for unchanged builds.I measured a large improvement over the previous setup. A lean version of the workflow with snapshot-local tools was around 36s-41s per matrix job.
The actual Cargo build step itself was only a few seconds, with the remaining time mostly runner setup, snapshot restore/save initiation, and tool setup.
This is an excellent result and clearly better than the S3/archive cache approaches I considered for this specific “preserve Cargo local build state” goal.
Current working workflow shape
This is a simplified version of the working shape:
This works, but it took a lot of workflow plumbing to get there.
sequenceDiagram participant Job as Matrix job participant Snapshot as runs-on/snapshot participant FS as /mnt/rust-cargo-snapshot participant Git as Manual checkout participant Cargo as Cargo build Job->>Snapshot: restore path with per-function identity Snapshot->>FS: mount restored EBS volume Job->>FS: create workspace, cargo-home, tool dirs Job->>Git: fetch requested ref Git->>FS: checkout only if HEAD differs Job->>Cargo: build function binary Cargo->>FS: reuse target fingerprints/artifacts Job->>FS: remove credentials before save Snapshot->>FS: unmount and start new snapshot in post stepFriction points
1. Snapshot identity is overloaded into
versionThe current inputs are:
versionappears to be both a schema/version bump and part of the snapshot lookup identity. For a matrix workflow, each matrix entry needs a distinct snapshot stream. Since snapshot lookup does not includepath, I had to do this:That works, but semantically it is a cache key, not a version.
It would be cleaner to have a first-class
keyinput:Then
versioncould mean only “break compatibility / force fresh snapshot” andkeycould mean “which snapshot stream is this?”2. No restore-key semantics
The current built-in branch fallback is useful:
For build state, it would be useful to expose something closer to
actions/cacherestore keys:Or a simpler RunsOn-native branch fallback:
3. Restore/save lifecycle is coupled
Today restore happens in the main action and save happens in the post step. That is simple, but it limits workflow control.
For PRs, a useful policy is:
This is possible with
saveexpressions, for example:However, a more advanced case would be “restore, build, then decide at runtime whether saving is worth it.” For example, skip saving if the restored snapshot already corresponds to this commit, or skip saving if no relevant files changed.
There is no way for a shell step after restore/build to tell the post step “do not save after all.”
This would be useful:
Or a file-command API:
The action could then skip the post-step snapshot if workflow logic determined it was unnecessary.
4. No outputs describing what was restored
For observability and debugging, it would be very helpful if the restore step exposed outputs such as:
I had to inspect action logs manually to answer questions like:
Outputs would make it easy to add workflow summaries and conditional behavior.
5. Directly mounting over
${{ github.workspace }}is dangerousThe first attempt mounted the snapshot directly at GitHub’s workspace path:
This caused
runs-on/snapshotpost-step save to fail:The job still completed successfully, but no snapshot was saved. The next run restored a blank volume again.
The working pattern was to mount somewhere else:
Then use:
as the actual repository checkout/build directory.
This pattern is not obvious from the docs. It may be worth documenting explicitly:
6.
actions/checkoutwas not ideal for restored workspacesI also found that
actions/checkoutcan be too aggressive for this use case. The goal is not just to get the right source contents into the workspace. The goal is to update a previously restored workspace in a way that resembles a localgit fetch/git checkoutwithout destroying the restoredtarget/tree or touching unchanged source files unnecessarily.In the first blank-volume run,
actions/checkoutprinted:and then failed during cleanup because the restored mount point was not yet a Git repository:
Even when
clean: falseis set,actions/checkoutstill has bootstrap behavior for an empty/non-repo target directory. That behavior is reasonable for normal ephemeral CI, but it is not ideal when the target directory is a restored build-state volume that should be preserved.The other important concern is file mtimes. Cargo freshness checks rely heavily on relationships between source mtimes, dep-info files, fingerprint files, build script outputs, and compiled artifacts. If checkout rewrites unchanged source files, Cargo can see source files as newer than restored target metadata and mark units dirty even when file contents are identical.
So the checkout operation for this pattern needs slightly different semantics:
I ended up writing a manual checkout step that:
.gitis missing.HEADis not already that SHA.git clean.This is important because Cargo freshness depends on unchanged source file mtimes relative to target metadata.
A helper action or documented recipe would be very useful:
Or the snapshot action docs could include a recommended checkout snippet for this pattern.
7. Matrix/workspace use case could use first-class examples
The current examples are mostly simple single-path examples. For a Rust workspace with many matrix builds, the important details are:
A Rust/Cargo example in the docs would help users avoid a lot of trial and error.
Suggested API improvements
Add
keyRecommended semantics:
Add restore-key or fallback controls
Option A, close to
actions/cache:Option B, simpler RunsOn-native branch fallback:
Add restore outputs
Add save outputs
Add runtime save control
For example:
Or expose a state file/env file that later steps can write to:
Document source/workspace snapshot pattern
Specifically document that users should prefer:
over mounting directly onto:
for source/build workspace snapshots.
Provide checkout guidance or helper
A helper or example that preserves restored state:
Desired final workflow shape
Ideally, a workflow could look more like this:
Why this matters
Rust CI performance is often bottlenecked by Cargo rebuilds. Generic archive caches are not enough to reproduce local no-op rebuild behavior.
runs-on/snapshotis the first mechanism I tested that actually got close to local behavior forcargo lambda buildin CI.The current action is already powerful enough to make this work, but the YAML is more complex than it needs to be for matrix/workspace build-state snapshots. A few small API additions would make this pattern much easier to adopt and less error-prone.