diff --git a/README.md b/README.md index dd1b8fc..14fc9cc 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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. @@ -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 diff --git a/action.yml b/action.yml index 18d8349..b4482f2 100644 --- a/action.yml +++ b/action.yml @@ -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: @@ -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 @@ -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 }} diff --git a/scripts/cache-stats-capture.sh b/scripts/cache-stats-capture.sh new file mode 100755 index 0000000..9e34bb0 --- /dev/null +++ b/scripts/cache-stats-capture.sh @@ -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 `\t` 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" diff --git a/scripts/cache-stats-report.sh b/scripts/cache-stats-report.sh new file mode 100755 index 0000000..0dcc421 --- /dev/null +++ b/scripts/cache-stats-report.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail +export LC_ALL=C + +# Renders the Nix cache summary to the GitHub Actions job summary. It reports: +# - which cache was restored (primary vs a prefix match) and its size; +# - what happened on save: a new cache uploaded (size + delta vs the restored +# cache), skipped because a cache for the key already existed (whether from +# an exact primary-key hit at restore, or a concurrent run that saved one +# while this job was still building), or a warning if the save appears to +# have failed; +# - a per-derivation breakdown: how many store paths came from the GitHub +# cache, upstream binary caches, or were built locally (listed when < 200). +# +# It runs in the job's post phase, ordered (see action.yml) to execute AFTER +# cache-nix-action's own save step, so the save outcome can be observed via the +# GitHub Actions Cache API - cache-nix-action exposes neither cache sizes nor +# the save decision as outputs. The built store, and whether a cache for the +# primary key already existed right before cache-nix-action's own save ran, +# are both captured separately in cache-stats-capture.sh, which runs just +# before that save. Everything here is best-effort: a failure must never fail +# the job, so the caller ignores the exit status. + +script_dir="$(dirname "${BASH_SOURCE[0]}")" +source "$script_dir/lib/cache-api.sh" + +state_dir="${RUNNER_TEMP:-/tmp}/nix-magic-setup" +summary_file="${GITHUB_STEP_SUMMARY:-/dev/stdout}" + +pre_file="$state_dir/pre-paths.txt" +post_file="$state_dir/post-paths.txt" +now_file="$state_dir/now.tsv" +meta_file="$state_dir/cache-meta.env" +pre_save_file="$state_dir/pre-save.env" + +hit_primary=""; hit_first_match=""; primary_key=""; restored_key="" +if [ -f "$meta_file" ]; then + # shellcheck source=/dev/null + . "$meta_file" + hit_primary="${CACHE_HIT_PRIMARY_KEY:-}" + hit_first_match="${CACHE_HIT_FIRST_MATCH:-}" + primary_key="${CACHE_PRIMARY_KEY:-}" + restored_key="${CACHE_RESTORED_KEY:-}" +fi + +primary_existed_pre_save="false"; primary_size_pre_save="" +if [ -f "$pre_save_file" ]; then + # shellcheck source=/dev/null + . "$pre_save_file" + primary_existed_pre_save="${CACHE_PRIMARY_EXISTED_PRE_SAVE:-false}" + primary_size_pre_save="${CACHE_PRIMARY_SIZE_PRE_SAVE:-}" +fi + +api_available=false +[ -n "${NMS_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && api_available=true + +# --- Restore section --------------------------------------------------------- +restored_size="$(nms_cache_size_bytes "$restored_key")" + +if [ "$hit_primary" = "true" ]; then + restored_line="✅ Restored the **primary** cache (\`${restored_key}\`), size **$(nms_human_size "$restored_size")**." +elif [ "$hit_first_match" = "true" ]; then + restored_line="♻️ Restored a **different** cache via prefix match (\`${restored_key}\`), size **$(nms_human_size "$restored_size")**." +else + restored_line="❌ No cache restored — cold store." +fi + +# --- Save section ------------------------------------------------------------- +# cache-nix-action decides whether to save based on its OWN check, at save +# time, for whether a cache with the primary key already exists - independent +# of whether restore hit the primary key earlier in the job. So "restore +# missed the primary key" does not imply "this run saved a new cache": a +# concurrent run (e.g. another workflow triggered by the same push, evaluating +# the same flake.lock/*.nix state) can race to save that exact key first. +# `primary_existed_pre_save` (checked immediately before cache-nix-action's own +# save step) captures that race; comparing it against the current state below +# tells us what actually happened, without needing an output cache-nix-action +# doesn't provide. +if [ "$hit_primary" = "true" ]; then + save_line="⏭️ Save skipped — exact primary-key hit at restore, the cache was already current." +elif [ "$api_available" != "true" ]; then + save_line="⬆️ A new cache may be saved for \`${primary_key}\` (outcome unavailable — no Cache API access)." +elif [ "$primary_existed_pre_save" = "true" ]; then + save_line="⏭️ Save skipped — a cache for \`${primary_key}\` already existed by save time (size **$(nms_human_size "$primary_size_pre_save")**), likely uploaded by a concurrent run." +else + saved_size="$(nms_cache_size_bytes "$primary_key")" + if [ -n "$saved_size" ]; then + delta="" + if [ -n "$restored_size" ]; then + d=$(( saved_size - restored_size )) + sign="+"; [ "$d" -lt 0 ] && sign="-" + delta=" (${sign}$(nms_human_size "${d#-}") vs restored)" + fi + save_line="⬆️ Saved a new cache (\`${primary_key}\`), size **$(nms_human_size "$saved_size")**${delta}." + else + save_line="⚠️ No cache found for \`${primary_key}\` after save — it may have failed." + fi +fi + +# --- Per-derivation breakdown -------------------------------------------------- +deriv_section="" +if [ -s "$now_file" ] && [ -f "$post_file" ]; then + [ -f "$pre_file" ] || : > "$pre_file" + workdir="$(mktemp -d)" + trap 'rm -rf "$workdir"' EXIT + tab="$(printf '\t')" + + cut -f1 "$now_file" > "$workdir/now.txt" + comm -23 "$post_file" "$pre_file" | comm -12 - "$workdir/now.txt" > "$workdir/from-github.txt" + comm -23 "$workdir/now.txt" "$post_file" > "$workdir/new.txt" + join -t "$tab" "$workdir/new.txt" "$now_file" > "$workdir/new-flagged.tsv" + awk -F'\t' '$2 == "built" { print $1 }' "$workdir/new-flagged.tsv" > "$workdir/built.txt" + awk -F'\t' '$2 == "sub" { print $1 }' "$workdir/new-flagged.tsv" > "$workdir/upstream.txt" + + github_count=$(wc -l < "$workdir/from-github.txt" | tr -d ' ') + built_count=$(wc -l < "$workdir/built.txt" | tr -d ' ') + upstream_count=$(wc -l < "$workdir/upstream.txt" | tr -d ' ') + + deriv_section="$( + echo "Store paths by source for this run:" + echo + echo "| Source | Paths |" + echo "| --- | ---: |" + echo "| ♻️ Restored from GitHub Actions cache | $github_count |" + echo "| ⬇️ Substituted from upstream caches | $upstream_count |" + echo "| 🔨 Built locally (no cache) | $built_count |" + echo + if [ "$built_count" -eq 0 ]; then + echo "Everything was served from a cache — nothing had to be built. 🎉" + elif [ "$built_count" -lt 200 ]; then + echo "
" + echo "🔨 Built locally ($built_count)" + echo + while IFS= read -r p; do echo "- \`$p\`"; done < <(sort "$workdir/built.txt") + echo + echo "
" + else + echo "> $built_count paths were built locally (too many to list individually)." + fi + )" +fi + +# --- Emit -------------------------------------------------------------------- +{ + echo "## ❄️ Nix cache" + echo + echo "$restored_line" + echo + echo "$save_line" + if [ -n "$deriv_section" ]; then + echo + echo "$deriv_section" + fi + echo +} >> "$summary_file" diff --git a/scripts/cache-stats-snapshot.sh b/scripts/cache-stats-snapshot.sh new file mode 100755 index 0000000..74303dc --- /dev/null +++ b/scripts/cache-stats-snapshot.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail +export LC_ALL=C + +# Records the set of valid Nix store paths at a point in time, so the post-run +# report can attribute each derivation to a source. Two snapshots are taken: +# pre - after Nix is installed but *before* the cache is restored. This is the +# baseline toolchain (fetched from upstream during install); it's +# treated as noise and excluded from the report. +# post - immediately *after* the cache is restored, so (post - pre) is exactly +# what the GitHub Actions cache provided. +# The report itself takes a third look at the store in the post step, after the +# build has run, to find what the build added on top of this. + +phase="${1:?usage: cache-stats-snapshot.sh }" + +state_dir="${RUNNER_TEMP:-/tmp}/nix-magic-setup" +mkdir -p "$state_dir" + +store_paths() { + # Exclude .drv files: they're instantiated locally during evaluation, never + # "built" or "substituted", so counting them would only add noise. We report + # on realised outputs and their dependencies. + nix --extra-experimental-features nix-command path-info --all 2>/dev/null \ + | awk '!/\.drv$/' | sort -u +} + +case "$phase" in + pre) + store_paths > "$state_dir/pre-paths.txt" + ;; + post) + store_paths > "$state_dir/post-paths.txt" + # Stash the cache restore result so the report can show it alongside the + # derivation-level breakdown. + { + printf 'CACHE_HIT_PRIMARY_KEY=%s\n' "${CACHE_HIT_PRIMARY_KEY:-}" + printf 'CACHE_HIT_FIRST_MATCH=%s\n' "${CACHE_HIT_FIRST_MATCH:-}" + printf 'CACHE_PRIMARY_KEY=%s\n' "${CACHE_PRIMARY_KEY:-}" + printf 'CACHE_RESTORED_KEY=%s\n' "${CACHE_RESTORED_KEY:-}" + } > "$state_dir/cache-meta.env" + ;; + *) + echo "cache-stats-snapshot.sh: unknown phase '$phase'" >&2 + exit 1 + ;; +esac diff --git a/scripts/lib/cache-api.sh b/scripts/lib/cache-api.sh new file mode 100755 index 0000000..b82a33b --- /dev/null +++ b/scripts/lib/cache-api.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Shared helpers for querying the GitHub Actions Cache API. cache-nix-action +# doesn't expose cache sizes (or the save outcome) as step outputs, so the +# cache stats scripts fall back to querying the API directly wherever they +# need that information. + +# Prints the largest size in bytes (across refs) of a cache with the given +# exact key, or nothing if unknown, not found, or there's no API access +# (missing token/repository). Never fails the caller. +nms_cache_size_bytes() { + local key="$1" + [ -n "$key" ] && [ -n "${NMS_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] || return 0 + local api="${GITHUB_API_URL:-https://api.github.com}" + curl -sfSL \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${NMS_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${api}/repos/${GITHUB_REPOSITORY}/actions/caches?key=${key}" 2>/dev/null \ + | jq -r '[.actions_caches[]?.size_in_bytes] | (max // empty)' 2>/dev/null || true +} + +# Renders a byte count as a human-readable IEC size, or "unknown" if empty. +nms_human_size() { + local b="${1:-}" + [ -n "$b" ] || { printf 'unknown'; return; } + numfmt --to=iec-i --suffix=B --format='%.1f' "$b" 2>/dev/null || printf '%s B' "$b" +}