Skip to content

Commit 5cf19ed

Browse files
Merge pull request #15 from curlewlabs-com/fix/audit-followup-and-local-mutex-bump
fix: re-audit follow-ups and local-mutex bump
2 parents d47d155 + 7f65a76 commit 5cf19ed

6 files changed

Lines changed: 97 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
shellcheck:
1111
runs-on: ubuntu-latest
1212
steps:
13-
- uses: actions/checkout@v6
13+
- uses: actions/checkout@v6.0.2
1414
- name: Shellcheck
1515
run: shellcheck lib/cache-restore.sh lib/cache-save.sh
1616

@@ -23,7 +23,7 @@ jobs:
2323
actionlint:
2424
runs-on: ubuntu-latest
2525
steps:
26-
- uses: actions/checkout@v6
26+
- uses: actions/checkout@v6.0.2
2727
- name: Run actionlint
2828
run: |
2929
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
@@ -32,7 +32,7 @@ jobs:
3232
integration-test:
3333
runs-on: ubuntu-latest
3434
steps:
35-
- uses: actions/checkout@v6
35+
- uses: actions/checkout@v6.0.2
3636

3737
- name: Restore against non-existent cache dir (cold start)
3838
id: restore-cold
@@ -346,6 +346,9 @@ jobs:
346346
|| (echo "Existing-entry guard failed: expected 'first', got '$content'" && exit 1)
347347
echo "Sequential idempotent save: OK"
348348
349+
# The "v2:" prefix in these marker assertions is the canonical marker
350+
# version from lib/cache-restore.sh (MARKER_VERSION). If that constant
351+
# is ever bumped, the literals below must be bumped in lockstep.
349352
- name: Restore writes v2 marker
350353
run: |
351354
mkdir -p /tmp/test-marker-src
@@ -425,3 +428,66 @@ jobs:
425428
[ "$(cat /tmp/local-cache/entries/concurrent-restore-key/data.txt)" = "shared" ] \
426429
|| (echo "Modifying A corrupted cache entry" && exit 1)
427430
echo "Concurrent restore isolation: OK"
431+
432+
# A second prefix restore against an already-populated target must hit
433+
# the marker-skip branch in cache-restore.sh (is_current on the
434+
# is_exact="false" path). Existing tests cover exact-hit-skip and
435+
# prefix-match-first-time, but not this second-call-is-skipped case —
436+
# which is the steady-state behavior for the README's restore → install
437+
# → save pattern when the caller's exact key is slightly newer than the
438+
# fallback. If this branch regresses, the restore silently starts doing
439+
# full rsync work on every invocation instead of constant-time work.
440+
- name: Second prefix restore against populated target is a marker-skip
441+
run: |
442+
mkdir -p /tmp/test-prefix-marker-src
443+
echo "v1-content" > /tmp/test-prefix-marker-src/data.txt
444+
sh lib/cache-save.sh /tmp/test-prefix-marker-src prefix-marker-v1 /tmp/local-cache
445+
446+
# First prefix restore populates the target with marker "v2:prefix-marker-v1".
447+
# The caller's exact key (v2) doesn't exist; restore-keys "prefix-marker-"
448+
# resolves to the only matching entry (v1).
449+
sh lib/cache-restore.sh /tmp/test-prefix-marker-dst prefix-marker-v2 /tmp/local-cache "prefix-marker-"
450+
marker=$(cat /tmp/test-prefix-marker-dst/.local-cache-restore)
451+
[ "$marker" = "v2:prefix-marker-v1" ] \
452+
|| (echo "Expected marker 'v2:prefix-marker-v1' after first prefix restore, got '$marker'" && exit 1)
453+
454+
# Modify the restored content — if the second restore skips via
455+
# marker-match, this modification survives (no rsync overwrite).
456+
# Same pattern as the "Second restore with same key is skipped" test,
457+
# but on the prefix-resolution branch.
458+
echo "modified-between-restores" > /tmp/test-prefix-marker-dst/data.txt
459+
sh lib/cache-restore.sh /tmp/test-prefix-marker-dst prefix-marker-v2 /tmp/local-cache "prefix-marker-"
460+
content=$(cat /tmp/test-prefix-marker-dst/data.txt)
461+
[ "$content" = "modified-between-restores" ] \
462+
|| (echo "Second prefix restore did not hit marker-skip: expected 'modified-between-restores', got '$content'" && exit 1)
463+
echo "Prefix restore marker-skip: OK"
464+
465+
# When a caller runs the README's canonical pattern (restore → install
466+
# → save on the same path) and the restore was a prefix hit, the target
467+
# ends up with a .local-cache-restore marker pointing at the matched
468+
# entry. The save step must exclude that marker from the new entry on
469+
# disk, or else every cache entry that was ever populated via a prefix
470+
# hit permanently carries a stale marker for the entry it was cloned
471+
# from. Harmless at runtime but pollutes cache entries with stale
472+
# metadata.
473+
- name: Save after prefix restore excludes restore marker
474+
run: |
475+
mkdir -p /tmp/test-save-exclude-src
476+
echo "source" > /tmp/test-save-exclude-src/data.txt
477+
sh lib/cache-save.sh /tmp/test-save-exclude-src save-exclude-v1 /tmp/local-cache
478+
479+
# Prefix restore leaves the marker file in the target.
480+
sh lib/cache-restore.sh /tmp/test-save-exclude-dst save-exclude-v2 /tmp/local-cache "save-exclude-"
481+
[ -f /tmp/test-save-exclude-dst/.local-cache-restore ] \
482+
|| (echo "Expected marker in target after prefix restore" && exit 1)
483+
484+
# Save under the caller's exact key. The new entry must not contain
485+
# the stale marker from the prefix hit.
486+
sh lib/cache-save.sh /tmp/test-save-exclude-dst save-exclude-v2 /tmp/local-cache
487+
[ -d /tmp/local-cache/entries/save-exclude-v2 ] \
488+
|| (echo "Expected new entry save-exclude-v2" && exit 1)
489+
[ ! -f /tmp/local-cache/entries/save-exclude-v2/.local-cache-restore ] \
490+
|| (echo "rsync did not exclude marker — new entry contains stale marker from prefix source" && exit 1)
491+
[ -f /tmp/local-cache/entries/save-exclude-v2/data.txt ] \
492+
|| (echo "Expected data.txt in new entry" && exit 1)
493+
echo "Save excludes restore marker: OK"

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ A GitHub composite action that provides local-disk caching for self-hosted runne
77
## Rules
88

99
- Shell scripts in `lib/` must use `#!/bin/sh` and pass `shellcheck` with no warnings.
10-
- No external dependencies beyond `rsync`, `sh`, and standard POSIX utilities. The one published-action dependency is [`curlewlabs-com/local-mutex`](https://github.com/curlewlabs-com/local-mutex), which `save/action.yml` uses to serialize per-key concurrent writers via the kernel's `lockf`/`flock` primitive. Pin local-mutex to a specific patch version (`@v1.0.0`, not `@v1`) so that updates land as reviewable PRs rather than silently following whatever the upstream major tag points at.
10+
- No external dependencies beyond `rsync`, `sh`, and standard POSIX utilities. The one published-action dependency is [`curlewlabs-com/local-mutex`](https://github.com/curlewlabs-com/local-mutex), which `save/action.yml` uses to serialize per-key concurrent writers via the kernel's `lockf`/`flock` primitive. Pin local-mutex to a specific patch version (e.g. `@v1.0.1`, not `@v1`) so that updates land as reviewable PRs rather than silently following whatever the upstream major tag points at.
1111
- The action interface (`action.yml`, `save/action.yml`) must remain compatible with `actions/cache` inputs/outputs (`path`, `key`, `restore-keys`, `cache-hit`, `cache-matched-key`).
1212
- Every change ships with a test in `.github/workflows/ci.yml`.
1313
- Tag releases as `v2`, `v3`, etc. (major only). Use floating major tags.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ With `local-cache`, the artifact lives on the machine's local disk. On the first
1616

1717
Cache entries are stored as plain directories under `cache-dir/entries/<key>/`. On restore, `rsync -a` copies the entry to the target path. A marker file (`.local-cache-restore`) in the target records which key was last restored:
1818

19-
- **Marker matches current key** → restore is skipped entirely (constant-time work)
19+
- **Marker matches the matched entry** → restore is skipped entirely (constant-time work). For prefix matches, "matched entry" is the resolved on-disk entry name, not the caller's `key` input.
2020
- **Marker missing or different key** → target is cleaned and re-synced from cache
2121
- **No marker (v1 upgrade)** → treated as stale, cleaned and re-synced
2222

@@ -158,7 +158,7 @@ Use `local-cache` when you cannot control where a tool installs itself. The Flut
158158

159159
## Limitations
160160

161-
- **No TTL or eviction.** Cache entries accumulate until manually deleted. For artifacts that change infrequently (e.g. Flutter SDK, updated monthly) this is fine. Clean up with `rm -rf cache-dir/entries/`.
161+
- **No TTL or eviction.** Cache entries accumulate until manually deleted. For artifacts that change infrequently (e.g. Flutter SDK, updated monthly) this is fine. Clean up with `rm -rf cache-dir/entries/*`.
162162
- **Each restore is a full copy.** When the marker doesn't match (version bump, first v2 restore), the full artifact is copied from cache to target. For a 1.8 GB Flutter SDK this takes a few seconds on SSD — trivial compared to the network download it replaces.
163163
- **macOS Spotlight indexing.** On macOS runners, restoring large cache entries (e.g. the Flutter SDK) can trigger `mds` / `mds_stores` to re-index the restored files, causing CPU spikes. Exclude the runner's root directory (or at minimum the `cache-dir`) from Spotlight indexing via System Settings > Spotlight > Privacy, or programmatically with `mdutil -i off /path/to/runner`.
164164
- **Windows Defender on WSL2.** If your runners run inside WSL2 and you notice CPU spikes from `MsMpEng.exe` after cache restores, Windows Defender may be scanning files written to the WSL2 filesystem. Add the WSL2 distribution's directory to the Defender exclusion list in Windows Security settings.

lib/cache-restore.sh

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@
2020
set -e
2121

2222
MARKER_NAME=".local-cache-restore"
23+
# Marker format version. Bumped when the on-disk layout of either the marker
24+
# file or the cache entries changes in a backward-incompatible way (e.g. v1
25+
# used hard links; v2 uses full rsync copies). The marker content is
26+
# "${MARKER_VERSION}:<entry name>", and a mismatch triggers a clean re-sync.
27+
# Referenced by literal in .github/workflows/ci.yml marker tests — keep those
28+
# literals in sync if you bump this.
29+
MARKER_VERSION="v2"
2330

2431
path_to_cache="$1"
2532
cache_key="$2"
@@ -59,7 +66,7 @@ append_summary() {
5966
# previous v2 restore. Returns 0 (true) if the marker matches.
6067
is_current() {
6168
marker="${path_to_cache}/${MARKER_NAME}"
62-
[ -f "$marker" ] && [ "$(cat "$marker")" = "v2:$1" ]
69+
[ -f "$marker" ] && [ "$(cat "$marker")" = "${MARKER_VERSION}:$1" ]
6370
}
6471

6572
do_restore() {
@@ -93,7 +100,7 @@ do_restore() {
93100
rsync -a "$entry_path/" "$path_to_cache/"
94101

95102
# Write the v2 marker so future restores with the same key skip.
96-
printf 'v2:%s' "$matched_key" > "${path_to_cache}/${MARKER_NAME}"
103+
printf '%s:%s' "$MARKER_VERSION" "$matched_key" > "${path_to_cache}/${MARKER_NAME}"
97104

98105
elapsed=$(( $(date +%s) - start_time ))
99106
size=$(du -sh "$path_to_cache" 2>/dev/null | cut -f1 || printf '?')
@@ -119,8 +126,13 @@ case "$safe_key" in
119126
.|..) printf '::error::cache-restore: key must not be "." or ".."\n'; exit 1 ;;
120127
esac
121128

122-
entry_count=$(find "${entries_dir}/" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l | tr -d ' ' || printf '0')
123-
printf '::debug::Checking local cache — key: %s, entries: %s\n' "$cache_key" "$entry_count"
129+
# Only count entries when debug logging is actually on — a production restore
130+
# that hits the marker-skip happy path must be constant-time work, and the
131+
# entries dir can hold enough directories that `find | wc -l` is a real cost.
132+
if [ "${RUNNER_DEBUG:-}" = "1" ]; then
133+
entry_count=$(find "${entries_dir}/" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l | tr -d ' ' || printf '0')
134+
printf '::debug::Checking local cache — key: %s, entries: %s\n' "$cache_key" "$entry_count"
135+
fi
124136

125137
if [ -d "${entries_dir}/${safe_key}" ]; then
126138
do_restore "${entries_dir}/${safe_key}" "$cache_key" "true"

lib/cache-save.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
# fully-written new one — never a partial directory.
2121
set -e
2222

23+
# SYNC: must match lib/cache-restore.sh:MARKER_NAME exactly.
24+
MARKER_NAME=".local-cache-restore"
25+
2326
path_to_cache="$1"
2427
cache_key="$2"
2528
cache_dir="$3"
@@ -77,7 +80,10 @@ cleanup_tmp() {
7780
trap cleanup_tmp EXIT INT TERM
7881

7982
printf '::debug::Saving to local cache: %s\n' "$cache_key"
80-
rsync -a "${path_to_cache}/" "${tmp_entry}/"
83+
# Exclude the restore marker so a prefix-hit restore followed by save on the
84+
# same path (restore → install → save, the canonical README pattern) doesn't
85+
# carry the previous entry's name into the new entry on disk.
86+
rsync -a --exclude="${MARKER_NAME}" "${path_to_cache}/" "${tmp_entry}/"
8187
mv "$tmp_entry" "${entries_dir}/${safe_key}"
8288

8389
elapsed=$(( $(date +%s) - start_time ))

save/action.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: 'Local Cache (Save)'
1+
name: 'Runner Local Cache (Save)'
22
description: >-
33
Save a directory to the local disk cache on self-hosted runners.
44
Call this after your install step, conditional on a cache miss from
@@ -32,7 +32,7 @@ runs:
3232
# machine reboot — which is why local-mutex needs no PID tracking or
3333
# stale-lock recovery code.
3434
- name: Save cache
35-
uses: curlewlabs-com/local-mutex@v1.0.0
35+
uses: curlewlabs-com/local-mutex@v1.0.1
3636
env:
3737
INPUT_PATH: ${{ inputs.path }}
3838
INPUT_KEY: ${{ inputs.key }}

0 commit comments

Comments
 (0)