6 Commits
Author SHA1 Message Date
saru d4b6457b06 fix: restore step died with exit 141 (SIGPIPE) on every cache hit — act runs step scripts with 'bash -e -o pipefail', so the 'FIRST_ENTRY=$(tar -tf ... | head -1)' detection line killed the step: head -1 closes the pipe after the first line, tar gets SIGPIPE (141), and pipefail propagates it as the script exit status (observed: 'Cache hit' then 'Process completed with exit code 141', step duration 0s, on deploy-leptos after this detection line was added on 2026-08-14)
The previous restores predate the detection line, which is why this only started failing on 2026-08-17. Suppress the pipeline status with '|| true' — FIRST_ENTRY still captures the first archive entry, and a genuinely unreadable archive degrades to the legacy fallback / cache-hit=false path as before.
2026-08-17 11:18:46 +08:00
saru 8cdec4603c Restore into the path input, not the archive's embedded absolute path
Restore only used the path input for a redundant mkdir and then extracted
to / using the absolute path baked into the archive, which pointed at the
stale per-run workspace of whichever run saved it. Extract relative
archives (<basename>/...) into the parent of the requested path; keep a
legacy fallback (extract to /) for old archives, which report cache-hit
only when the requested path actually gains content.
2026-08-14 23:28:31 +08:00
saru 43f1755c0e Touch archives on hit and prune the cache store after restore
Record last use by touching the archive so the retention policy can
expire by access time, and run prune.sh after every restore (even on
miss) to keep the shared store trimmed: keep the 3 newest archives per
key-prefix family, expire anything older than 7 days, hard-cap the store
at 2 GB (oldest first, trumping the keep set), never touch files younger
than 1h, and drop stale run workspaces under ~/.cache/act older than 2
days.
2026-08-14 22:54:47 +08:00
saru 2d128b5303 Add more debug output: key-file, path input, and expanded path on restore 2026-05-22 02:16:56 +08:00
saru 2d57ace5e4 Change path default to github.workspace 2026-05-22 02:02:48 +08:00
saru e744d2a004 Create restore-only action 2026-05-17 02:03:11 +08:00
3 changed files with 195 additions and 15 deletions
+31 -15
View File
@@ -3,13 +3,6 @@ Cache Action
A Gitea Action that caches build files as `tar.xz` archives on the runner filesystem. Simple, fast, no external dependencies. A Gitea Action that caches build files as `tar.xz` archives on the runner filesystem. Simple, fast, no external dependencies.
## Branches
| Branch | Purpose |
|--------|---------|
| `restore` | Restores cache archive if it exists |
| `save` | Saves cache archive (use after build) |
## Inputs ## Inputs
| Input | Description | Required | Default | | Input | Description | Required | Default |
@@ -22,20 +15,43 @@ A Gitea Action that caches build files as `tar.xz` archives on the runner filesy
| Output | Description | | Output | Description |
|--------|-------------| |--------|-------------|
| `cache-hit` | `true` if exact key match found, `false` otherwise (restore only) | | `cache-hit` | `true` if exact key match found, `false` otherwise |
## How It Works ## How It Works
1. Hashes `key-file` with SHA-256, combines with `key-prefix` as `<prefix>-<hash>` 1. Hashes `key-file` with SHA-256, combines with `key-prefix` as `<prefix>-<hash>`
2. Stores archives at `~/.cache/.cache-store/<key>.tar.xz` 2. Stores archives at `~/.cache/.cache-store/<key>.tar.xz`
3. `restore` branch extracts the archive, `save` branch creates one 3. Restores by extracting the archive to `/`, saves by creating one
4. Touches the archive on every hit, so the retention policy can expire by
last use
5. Archives store the cached directory **relative** (`<basename>/...`), not its
absolute path — a later run restores into its own `path` location, so the
cache works across runs even though each run gets a fresh workspace
## Cache Retention
Archives are content-addressed, so old keys are never needed again — the store
is pruned automatically on every restore/save (best-effort, safe under
concurrent runners):
- The newest `CACHE_KEEP_N` (default `3`) archives per key-prefix family are
always kept, so reverting e.g. `Cargo.lock` still hits an older key
- Anything older than `CACHE_TTL_DAYS` (default `7`) outside the keep set is
deleted
- A hard size cap `CACHE_CAP_GB` (default `2`) deletes oldest-first when
exceeded
- Files younger than 1 hour are never touched (in-flight save protection)
- Stale run workspaces under `~/.cache/act/*/hostexecutor` older than
`CACHE_WORKSPACE_TTL_DAYS` (default `2`) are removed
Override the defaults per-job via `env:` (e.g. `env: { CACHE_KEEP_N: 5 }`).
## Usage ## Usage
This action must be used **twice** in your workflow: This action must be used **twice** in your workflow:
1. **Before build**use `@restore` to restore cache if it exists 1. **Before build** — restores cache if it exists
2. **After build**use `@save` to save the cache archive 2. **After build**saves the cache archive (only on cache miss)
## Rust Example ## Rust Example
@@ -54,7 +70,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# 1. Restore cache before build # 1. Restore cache before build
- uses: Actions/Cache@restore - uses: Actions/Cache@main
id: cache id: cache
with: with:
key-prefix: 'cargo-registry' key-prefix: 'cargo-registry'
@@ -65,7 +81,7 @@ jobs:
run: cargo build --release run: cargo build --release
# 2. Save cache after build # 2. Save cache after build
- uses: Actions/Cache@save - uses: Actions/Cache@main
with: with:
key-prefix: 'cargo-registry' key-prefix: 'cargo-registry'
key-file: 'Cargo.lock' key-file: 'Cargo.lock'
@@ -89,7 +105,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# 1. Restore cache # 1. Restore cache
- uses: Actions/Cache@restore - uses: Actions/Cache@main
id: cache id: cache
with: with:
key-prefix: 'rust' key-prefix: 'rust'
@@ -104,7 +120,7 @@ jobs:
run: cargo build --release run: cargo build --release
# 2. Save cache # 2. Save cache
- uses: Actions/Cache@save - uses: Actions/Cache@main
with: with:
key-prefix: 'rust' key-prefix: 'rust'
key-file: 'Cargo.lock' key-file: 'Cargo.lock'
+74
View File
@@ -0,0 +1,74 @@
name: 'Cache Restore'
description: 'Restore cache from tar.xz archive on the runner filesystem'
inputs:
path:
description: 'Directory to cache'
required: false
default: ${{ github.workspace }}
key-prefix:
description: 'Prefix for the cache key'
required: true
key-file:
description: 'File to hash for the cache key'
required: true
outputs:
cache-hit:
description: 'true if exact key match found, false otherwise'
value: ${{ steps.restore.outputs.cache-hit }}
runs:
using: "composite"
steps:
- name: Restore cache
id: restore
shell: bash
run: |
CACHE_DIR="$HOME/.cache/.cache-store"
mkdir -p "$CACHE_DIR"
KEY_HASH=$(sha256sum "${{ inputs.key-file }}" | awk '{print $1}')
CACHE_KEY="${{ inputs.key-prefix }}-${KEY_HASH}"
ARCHIVE="${CACHE_DIR}/${CACHE_KEY}.tar.xz"
EXPANDED_PATH=$(eval echo "${{ inputs.path }}")
echo "Key file: ${{ inputs.key-file }}"
echo "Path input: ${{ inputs.path }}"
echo "Expanded path: ${EXPANDED_PATH}"
echo "Cache key: ${CACHE_KEY}"
echo "Archive path: ${ARCHIVE}"
if [ -f "$ARCHIVE" ]; then
echo "Cache hit: ${CACHE_KEY}"
mkdir -p "$EXPANDED_PATH"
# New-format archives hold entries relative to the cached directory
# (<basename>/...), so extract into the parent of the requested
# path. Legacy archives embed the absolute path of the run that
# saved them; fall back to extracting them to "/" (status quo,
# they are TTL-pruned within the retention window anyway).
# NOTE: `|| true` is mandatory — act runs step scripts with
# `bash -e -o pipefail`, and `head -1` closes the pipe after the
# first line, so tar dies with SIGPIPE (141) and pipefail turns
# that into a step failure (restore only ever "worked" before
# this detection line existed).
FIRST_ENTRY=$(tar -tf "$ARCHIVE" 2>/dev/null | head -1 || true)
if [ "${FIRST_ENTRY%%/*}" = "$(basename "$EXPANDED_PATH")" ]; then
echo "Restoring to: $(dirname "$EXPANDED_PATH")"
tar -xf "$ARCHIVE" -C "$(dirname "$EXPANDED_PATH")" 2>/dev/null
else
tar -xf "$ARCHIVE" -C "/" 2>/dev/null
fi
if [ -d "$EXPANDED_PATH" ] && [ -n "$(ls -A "$EXPANDED_PATH" 2>/dev/null)" ]; then
# record last use so the retention policy can expire by access time
touch "$ARCHIVE"
echo "cache-hit=true" >> $GITHUB_OUTPUT
else
echo "Cache restore failed"
echo "cache-hit=false" >> $GITHUB_OUTPUT
fi
else
echo "Cache miss: ${CACHE_KEY}"
echo "cache-hit=false" >> $GITHUB_OUTPUT
fi
# Trim the shared cache store and stale workspaces (best-effort)
bash "${{ github.action_path }}/prune.sh" || true
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Prune the shared runner build-cache store (~/.cache/.cache-store) and stale
# run workspaces (~/.cache/act). Best-effort: safe to run from any job on any
# runner, since all runners on a host share the same store.
#
# Policy (per family, where a family is the key-prefix, e.g. `leptos-target`):
# - the newest CACHE_KEEP_N archives per family are always kept (rollback
# safety: reverting Cargo.lock must still hit an older key)
# - any archive older than CACHE_TTL_DAYS outside the keep set is deleted
# - a hard CACHE_CAP_GB size cap deletes oldest-first when exceeded
# - files younger than 1 hour are never touched (in-flight save protection;
# saves are atomic, see the save action: write .tmp then mv)
#
# Overrides via env: CACHE_KEEP_N (default 3), CACHE_TTL_DAYS (default 7),
# CACHE_CAP_GB (default 2), CACHE_WORKSPACE_TTL_DAYS
# (default 2)
set -u
CACHE_DIR="$HOME/.cache/.cache-store"
KEEP_N="${CACHE_KEEP_N:-3}"
TTL_SECS=$(( ${CACHE_TTL_DAYS:-7} * 86400 ))
CAP_BYTES=$(( ${CACHE_CAP_GB:-2} * 1024 * 1024 * 1024 ))
SAFETY_SECS=3600
WORKSPACE_TTL_DAYS="${CACHE_WORKSPACE_TTL_DAYS:-2}"
[ -d "$CACHE_DIR" ] || exit 0
NOW=$(date +%s)
# stale tmp files left by interrupted saves
find "$CACHE_DIR" -maxdepth 1 -name '*.tar.xz.tmp' -mmin +60 -delete 2>/dev/null
# newest KEEP_N archives per family are protected from deletion
declare -A protected=()
declare -A fam_count=()
while IFS=$'\t' read -r mt name; do
fam=$(sed -E 's/-[0-9a-f]{64}\.tar\.xz$//' <<< "$name")
if [ -n "$fam" ] && [ "${fam_count[$fam]:-0}" -lt "$KEEP_N" ]; then
protected[$name]=1
fam_count[$fam]=$(( ${fam_count[$fam]:-0} + 1 ))
fi
done < <(find "$CACHE_DIR" -maxdepth 1 -name '*.tar.xz' -printf '%T@\t%f\n' | sort -rn)
# deletion candidates: not protected, older than 1h, oldest first
declare -a candidates=()
while IFS=$'\t' read -r mt name; do
[ "${protected[$name]:-0}" = "1" ] && continue
mtime=$(printf '%.0f' "$mt")
[ $(( NOW - mtime )) -lt "$SAFETY_SECS" ] && continue
candidates+=("$mtime|$name")
done < <(find "$CACHE_DIR" -maxdepth 1 -name '*.tar.xz' -printf '%T@\t%f\n' | sort -n)
# TTL pass: drop archives past their retention window
deleted=0
for c in "${candidates[@]:-}"; do
[ -n "$c" ] || continue
mtime="${c%%|*}"; name="${c#*|}"
if [ $(( NOW - mtime )) -gt "$TTL_SECS" ]; then
rm -f "$CACHE_DIR/$name" && deleted=$((deleted + 1))
fi
done
[ "$deleted" -gt 0 ] && echo "prune: deleted $deleted archive(s) older than ${CACHE_TTL_DAYS:-7} day(s)"
# size-cap pass: keep the store under CAP_BYTES, oldest first. The cap is a
# hard backstop and overrides keep-N protection (only the 1h safety floor is
# absolute), so the size bound always holds even for small families.
total=$(du -sb "$CACHE_DIR" 2>/dev/null | awk '{print $1}')
if [ "${total:-0}" -gt "$CAP_BYTES" ]; then
while IFS=$'\t' read -r mt name; do
[ "${total:-0}" -le "$CAP_BYTES" ] && break
mtime=$(printf '%.0f' "$mt")
[ $(( NOW - mtime )) -lt "$SAFETY_SECS" ] && continue
if [ -f "$CACHE_DIR/$name" ]; then
size=$(stat -c %s "$CACHE_DIR/$name")
rm -f "$CACHE_DIR/$name" && total=$(( total - size ))
fi
done < <(find "$CACHE_DIR" -maxdepth 1 -name '*.tar.xz' -printf '%T@\t%f\n' | sort -n)
echo "prune: store size now $(du -sh "$CACHE_DIR" 2>/dev/null | cut -f1) (cap ${CACHE_CAP_GB:-2}G)"
fi
# stale run workspaces: every run gets a fresh ~/.cache/act/<id>/hostexecutor
# dir that is never reused, so old ones are dead weight (they can hold entire
# target/ dirs); nothing younger than the floor is touched
ws_deleted=0
while IFS= read -r ws; do
[ -n "$ws" ] || continue
rm -rf "$ws" && ws_deleted=$((ws_deleted + 1))
done < <(find "$HOME/.cache/act" -maxdepth 2 -type d -name hostexecutor -mtime +"$WORKSPACE_TTL_DAYS" 2>/dev/null)
find "$HOME/.cache/act" -maxdepth 1 -type d -empty -delete 2>/dev/null || true
[ "$ws_deleted" -gt 0 ] && echo "prune: removed $ws_deleted stale workspace(s) older than ${WORKSPACE_TTL_DAYS} day(s)"