Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
74d71df
Add configurable cache size and per-derivation cache stats
Jul 23, 2026
7b93a41
Forward a change-sentinel to comment-flake-lock-changelog (testing)
Jul 23, 2026
10b5480
Shorten input descriptions; keep detail in the README
Jul 23, 2026
b860b86
Report cache restore/save details and sizes in the stats summary
Jul 23, 2026
54b9485
Fix cache stats: primary-key output is never set; race-safe save dete…
Jul 23, 2026
179b106
Bump comment-flake-lock-changelog testing pin to latest main
Jul 23, 2026
f14bc8d
Fix action load failure: GitHub's action.yml parser rejects YAML anchors
Jul 23, 2026
432444e
Rename change-sentinel input to changelog-filter
Jul 23, 2026
26964fb
Bump comment-flake-lock-changelog testing pin to bd80de7
Jul 24, 2026
bdbcc02
Bump comment-flake-lock-changelog testing pin to 5c7ef97
Jul 24, 2026
05db4f9
Run changelog-filter before the cache restore, with build-filter-gc e…
Jul 24, 2026
c619005
Pin comment-flake-lock-changelog to claude/build-filter-gc for testing
Jul 24, 2026
c7a0252
Use build-filter-skip-checkout + git+file://?rev= to halve disk usage
Jul 24, 2026
f12cb14
Drop build-filter-skip-checkout: it doesn't work
Jul 24, 2026
17e658e
Bump comment-flake-lock-changelog testing pin to 5d4a1c6
Jul 24, 2026
e6e7e96
Bump uncached-build listing limit to 200, bust cache on gc size change
Jul 24, 2026
30b8cbb
Bump comment-flake-lock-changelog testing pin to 5b367a2
Jul 24, 2026
9de4a73
Bump comment-flake-lock-changelog testing pin to 8b4c27f
Jul 24, 2026
12e6699
Bump comment-flake-lock-changelog testing pin to b055b0e
Jul 24, 2026
9c1448e
fix: bump comment-flake-lock-changelog pin to main (fixes CI-killing …
mdarocha Aug 11, 2026
0da206f
perf: skip redundant pre-save Cache API call on exact primary-key hit
mdarocha Aug 11, 2026
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
82 changes: 81 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ single drop-in action.

- Installing Nix using [cachix/install-nix-action](https://github.com/cachix/install-nix-action)
- Caching Nix derivations using [nix-community/cache-nix-action](https://github.com/nix-community/cache-nix-action)
- Reporting per-derivation cache stats to the GitHub Actions job summary — how many store paths
were restored from the GitHub cache, substituted from upstream caches, or built locally
- Automagically setting up environments from `.envrc` using direnv
- Commenting with [mdarocha/comment-flake-lock-changelog](https://github.com/mdarocha/comment-flake-lock-changelog) when a PR updates `flake.lock`
- Freeing up runner disk space before installing Nix using [wimpysworld/nothing-but-nix](https://github.com/wimpysworld/nothing-but-nix)
Expand Down Expand Up @@ -49,6 +51,8 @@ jobs:
|--------------------|-------------------------------------------------------------------------------------------------------|----------------------|
| `token` | Github authentication token to use | `${{ github.token }}` |
| `free-up-all-storage` | Aggressively free up all possible disk space on the runner before installing Nix, using [wimpysworld/nothing-but-nix](https://github.com/wimpysworld/nothing-but-nix) | `false` |
| `max-cached-store-size` | Maximum uncompressed Nix store size to keep in the cache (e.g. `8G`, `512M`); an empty string disables garbage collection. See [Cache size](#cache-size). | `8G` |
| `changelog-filter` | Shell command forwarded to [comment-flake-lock-changelog](https://github.com/mdarocha/comment-flake-lock-changelog)'s `build-filter`, to hide `flake.lock` changelog commits that don't affect your build output. See its [README](https://github.com/mdarocha/comment-flake-lock-changelog#build-filter). | `""` |

### Freeing up storage

Expand All @@ -68,6 +72,82 @@ and is skipped gracefully on other platforms.
free-up-all-storage: true
```

### Cache size

The Nix store is cached with [nix-community/cache-nix-action](https://github.com/nix-community/cache-nix-action).
Just before a new cache is saved, the action garbage-collects old store paths until the store is at or
below `max-cached-store-size` (default `8G`), so the cache doesn't grow without bound.

A few things worth knowing when tuning this:

- **The limit is on the uncompressed store.** That's what the underlying garbage collector measures
(`nix store gc --max`). The cache uploaded to GitHub is compressed and in practice ends up roughly
2–4x smaller (e.g. a ~5.5 GiB store compresses to under ~2 GiB). There is no way to cap the
compressed size directly, because garbage collection happens before compression — use that ratio as a
rule of thumb. GitHub gives each repository 10 GiB of Actions cache and evicts least-recently-used
entries beyond that, so a store of a handful of GiB leaves plenty of headroom.
- **Set the limit above what a full build produces.** Garbage collection only runs when the store
exceeds the limit *and* the primary key didn't hit exactly. Keeping the ceiling comfortably above your
build's store size means the freshly-built paths survive collection and actually get saved (and then
restored fully warm on the next run) instead of being collected away and rebuilt every time. This is
why the default is a generous `8G` rather than something small.
- **Some commands don't create GC roots.** Notably `nix flake check` builds derivations without leaving
a GC root, so their outputs count as garbage. They're kept in the cache only if they fit under this
limit — another reason to keep it generous.
- **Disabling collection.** Set `max-cached-store-size` to an empty string to skip garbage collection
entirely and cache the whole store. Only do this if you're confident the store stays comfortably under
GitHub's cache limits.
- **Changing this value busts the cache.** It's part of the cache's primary key, so a run with a new
`max-cached-store-size` always saves a fresh cache under the new target instead of reusing one
collected under the old one.

```yaml
- uses: mdarocha/nix-magic-setup@v1.1.0
with:
max-cached-store-size: 6G
```

### Cache stats

After your build steps have run, the action writes a summary to the
[job summary](https://github.blog/news-insights/product-news/supercharging-github-actions-with-job-summaries/)
covering the whole cache lifecycle.

**Restore and save.** Which cache was restored — the exact **primary** cache or a **different**
prefix-matched one — and its size, followed by what happened on save: a new cache uploaded (with its
size and the delta versus the restored cache), the save skipped (either an exact primary-key hit at
restore, or a cache for that key already existing by save time — which happens when a concurrent run,
e.g. another workflow triggered by the same push, races to save the same key first), or a warning if no
cache turns up afterwards. Sizes come from the GitHub Actions Cache API, so the job needs `actions: read`
(already required by the [cache](#cache-size)).

**Per-derivation breakdown.** Where each Nix store path came from:

- **♻️ Restored from GitHub Actions cache** — paths the cache restored, so they didn't need to be
fetched or built.
- **⬇️ Substituted from upstream caches** — paths pulled from binary caches (`cache.nixos.org`, Cachix,
and any `extra-substituters` from your `flake.nix`) during the build.
- **🔨 Built locally** — paths that were built on the runner because no cache had them. When there are
fewer than 200, they're listed individually so you can see exactly what wasn't cached.

The breakdown snapshots the store before and after the cache is restored, and classifies whatever the
build adds using Nix's own `ultimate` flag (set on locally-built paths). Reporting runs in the job's
post phase — after your build, and ordered around cache-nix-action's own save so it can observe the
outcome — which the action arranges via
[pyTooling/Actions/with-post-step](https://github.com/pyTooling/Actions), since a composite action
can't declare a post step of its own. No configuration is required; it reports automatically.

### Changelog filter

`changelog-filter` runs before the Nix store cache is restored, not after — deliberately. Determining
build relevance evaluates the changed input (e.g. `nixpkgs`) at various commits, and each one gets
imported into the Nix store fresh, uncached, so a wide-reaching range (a multi-day `nixpkgs` bump can
be thousands of commits) can use a meaningful amount of disk before it's done. It also runs with
comment-flake-lock-changelog's `build-filter-gc: true`, so it garbage-collects between each of those
builds — only safe to run here, before the cache is restored, since running it after would risk
collecting away the cache that was just restored (a merely-*restored* store path isn't necessarily a
GC root).

## Permissions required

This action uses the workflows' `GITHUB_TOKEN` by default. Certain features require specific permissions to work.
Expand All @@ -84,4 +164,4 @@ Certain features also only work in the context of a cloned repository, so they r

In the future, this action is planned to also:
- Comment on PRs with [nix-diff](https://github.com/Gabriella439/nix-diff)
- Show stats like build times, cache hits vs. misses in GitHub Actions summaries
- Show build times in GitHub Actions summaries alongside the cache stats
92 changes: 84 additions & 8 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ inputs:
description: "Aggressively free up all possible disk space on the runner before installing Nix, using wimpysworld/nothing-but-nix. When false, a minimal amount of space is still reclaimed"
required: false
default: "false"
max-cached-store-size:
description: "Maximum uncompressed Nix store size to keep in the cache (e.g. 8G, 512M); an empty string disables garbage collection. See the Cache size section in the README."
required: false
default: "8G"
changelog-filter:
description: "Shell command forwarded to comment-flake-lock-changelog's build-filter, to hide flake.lock changelog commits that don't affect your build output. See comment-flake-lock-changelog's README."
required: false
default: ""
runs:
using: composite
steps:
Expand All @@ -29,20 +37,94 @@ runs:
shell: bash
run: bash "${{ github.action_path }}/scripts/setup-nixconfig.sh"

# Runs here, before the cache is restored, so build-filter-gc: true is safe: the
# store is still essentially empty (just the Nix installation), so gc between
# build-filter's builds can't collect away anything the rest of the job needs.
# Running this after cache restore would risk gc'ing the cache that was just
# restored.
#
# Pinned to main rather than a tagged release: build-filter/build-filter-gc are
# still pre-release (tracked in comment-flake-lock-changelog#301). Bump this SHA
# when main moves; switch to a version tag once a release ships.
#
# build-filter-skip-checkout doesn't work: Nix's git fetcher (libgit2) can't
# lazily fetch blobs from a blobless clone's promisor remote the way `git
# checkout` can, so it fails with "object not found" once it needs one. Don't
# re-add it. `git+file://...?rev=...` (used below) is still worth it on its own:
# it reads the commit straight from the object database the checkout already
# populated, instead of re-hashing the working tree the way `path:` would.
- uses: mdarocha/comment-flake-lock-changelog@8f9c930732a4ac2b1a08ade42a0e8da7e40cb442 # main
if: github.event_name == 'pull_request'
with:
pull-request-number: ${{ github.event.pull_request.number }}
build-filter: ${{ inputs.changelog-filter }}
build-filter-gc: "true"
token: ${{ inputs.token }}

- name: Snapshot Nix store before cache restore
shell: bash
run: bash "${{ github.action_path }}/scripts/cache-stats-snapshot.sh" pre

# Registered BEFORE the cache step so its post step runs AFTER
# cache-nix-action's save (post steps run in reverse registration order).
# That lets the report observe the save outcome and query the Actions Cache
# API for cache sizes. Composite actions can't declare a post step of their
# own, so this borrows one via with-post-step.
- name: Report cache stats after build
uses: pyTooling/Actions/with-post-step@fdcb8e6fb145f72c8e2c9c3ff6160dc62cc3e22c # v7.9.0
env:
NMS_TOKEN: ${{ inputs.token }}
with:
key: NMS_REPORT
main: 'echo "nix-magic-setup: Nix cache stats will be reported in the post step, after the build."'
post: 'bash "${{ github.action_path }}/scripts/cache-stats-report.sh"'

- name: Setup Nix Cache
id: nix-cache
uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with:
nix: true
save: true
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
primary-key: nix-${{ runner.os }}-${{ inputs.max-cached-store-size }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
gc-max-store-size: 2G
gc-max-store-size: ${{ inputs.max-cached-store-size }}
purge: true
purge-prefixes: nix-${{ runner.os }}-
purge-primary-key: never
purge-last-accessed: "P14D"
token: ${{ inputs.token }}

- name: Snapshot Nix store after cache restore
shell: bash
env:
CACHE_HIT_PRIMARY_KEY: ${{ steps.nix-cache.outputs.hit-primary-key }}
CACHE_HIT_FIRST_MATCH: ${{ steps.nix-cache.outputs.hit-first-match }}
# cache-nix-action doesn't expose `primary-key` as a real step output in
# this combined restore+save usage (it only persists it as internal
# cross-phase state), so it's always empty - use the same literal key
# expression given to its `primary-key` input above instead. (A YAML
# anchor/alias would avoid this duplication, but GitHub's action.yml
# parser rejects anchors outright, so it has to be spelled out here
# and in the "Capture Nix store after build" step below - keep both
# in sync with the `primary-key:` input above if this ever changes.)
CACHE_PRIMARY_KEY: nix-${{ runner.os }}-${{ inputs.max-cached-store-size }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
CACHE_RESTORED_KEY: ${{ steps.nix-cache.outputs.restored-key }}
run: bash "${{ github.action_path }}/scripts/cache-stats-snapshot.sh" post

# Registered AFTER the cache step so its post step runs BEFORE
# cache-nix-action's garbage collection and save. That captures the full
# built store for the per-derivation breakdown, before any paths are
# collected away.
- name: Capture Nix store after build
uses: pyTooling/Actions/with-post-step@fdcb8e6fb145f72c8e2c9c3ff6160dc62cc3e22c # v7.9.0
env:
NMS_TOKEN: ${{ inputs.token }}
CACHE_PRIMARY_KEY: nix-${{ runner.os }}-${{ inputs.max-cached-store-size }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
with:
key: NMS_CAPTURE
main: 'echo "nix-magic-setup: will capture the Nix store after the build for cache stats."'
post: 'bash "${{ github.action_path }}/scripts/cache-stats-capture.sh"'

- name: Check for .envrc
id: check-envrc
shell: bash
Expand All @@ -55,9 +137,3 @@ runs:
if: steps.check-envrc.outputs.exists == 'true'
shell: bash
run: bash "${{ github.action_path }}/scripts/setup-direnv.sh"

- uses: mdarocha/comment-flake-lock-changelog@709edb53d1a4937e9792876a036671f3316f1186 # v1.0.2
if: github.event_name == 'pull_request'
with:
pull-request-number: ${{ github.event.pull_request.number }}
token: ${{ inputs.token }}
64 changes: 64 additions & 0 deletions scripts/cache-stats-capture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C

# Captures the Nix store contents after the build but BEFORE cache-nix-action's
# garbage-collection/save runs, recording each path's `ultimate` flag (true =
# built locally, false = substituted). This runs in the job's post phase, and is
# ordered to execute before cache-nix-action's own post step (see action.yml),
# so the derivation-level stats reflect the full built store rather than the
# post-GC one. Best-effort: never fail the job.
#
# It also checks, at this same point, whether a cache for CACHE_PRIMARY_KEY
# already exists - i.e. immediately before cache-nix-action's own save step
# runs. cache-nix-action performs the same check internally to decide whether
# to upload a new cache, but doesn't expose that decision as an output. Doing
# our own check right beforehand lets the report distinguish "this run's save
# genuinely uploaded a new cache" from "a cache for this key already existed
# by save time" (e.g. a concurrent workflow run with the same flake.lock/*.nix
# state raced to save it first).

script_dir="$(dirname "${BASH_SOURCE[0]}")"
source "$script_dir/lib/cache-api.sh"

state_dir="${RUNNER_TEMP:-/tmp}/nix-magic-setup"
mkdir -p "$state_dir"

# `path-info --json` is an object on newer Nix and an array on older Nix;
# normalise both to `<path>\t<built|sub>` lines, excluding .drv files.
if ! nix --extra-experimental-features nix-command path-info --all --json 2>/dev/null \
| jq -r '
(if type == "object" then to_entries | map(.value + {path: .key}) else . end)
| .[]
| select((.path | endswith(".drv")) | not)
| [.path, (if .ultimate then "built" else "sub" end)] | @tsv
' \
| sort -u > "$state_dir/now.tsv"; then
: > "$state_dir/now.tsv"
fi

# When restore already hit the primary key exactly, the report's save section
# short-circuits without ever consulting CACHE_PRIMARY_EXISTED_PRE_SAVE or
# CACHE_PRIMARY_SIZE_PRE_SAVE (see cache-stats-report.sh), so querying the API
# here in that case would just be a wasted round trip. cache-meta.env, from
# the earlier "post" snapshot, is how we know the restore outcome.
hit_primary=""
meta_file="$state_dir/cache-meta.env"
if [ -f "$meta_file" ]; then
# shellcheck source=/dev/null
. "$meta_file"
hit_primary="${CACHE_HIT_PRIMARY_KEY:-}"
fi

if [ "$hit_primary" = "true" ]; then
existed=true
pre_save_size=""
else
pre_save_size="$(nms_cache_size_bytes "${CACHE_PRIMARY_KEY:-}")"
existed=false
[ -n "$pre_save_size" ] && existed=true
fi
{
printf 'CACHE_PRIMARY_EXISTED_PRE_SAVE=%s\n' "$existed"
printf 'CACHE_PRIMARY_SIZE_PRE_SAVE=%s\n' "$pre_save_size"
} > "$state_dir/pre-save.env"
Loading