Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 39 additions & 0 deletions shared-actions/setup-node-with-cache/CACHE-STRATEGY-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,45 @@ with:

---

## Advanced: Lockfile-Only Cache Key

By default the dependency cache key hashes the lockfile **and every `package.json`**.
In a workspace repo with many packages that makes the cache far more fragile than it
needs to be: editing a description, bumping a package version, or adding a script
invalidates the entire cache even though none of them change what gets installed.

```yaml
uses: Typeform/.github/shared-actions/setup-node-with-cache@v1
with:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
lockfile-only-cache-key: true
```

**Measured on `bob-the-builder`** (17 workspaces), over the last 60 commits on `main`:

| Cache key | Commits that invalidate the cache |
|---|---|
| lockfile + all `package.json` (default) | 30 / 59 = **51%** |
| lockfile only | 16 / 59 = **27%** |

**Why it is safe:** the lockfile already pins the fully resolved dependency tree. Yarn
and pnpm rewrite it whenever a manifest edit actually changes resolution, so a
`package.json` change that matters is always accompanied by a lockfile change. A change
that does not touch the lockfile cannot alter what gets installed. `--frozen-lockfile`
enforces this — a manifest edit needing new resolutions fails the build rather than
silently installing something different.

All 14 commits in that sample that touched a `package.json` without touching
`yarn.lock` were verified by hand: 12 were non-dependency edits, and the other 2 added
packages that were already resolved in the lockfile (pinned via root `resolutions`, or
already present as a transitive dependency).

**When to use:**
- Workspace/monorepo repos with many `package.json` files
- Large caches, where each avoided miss saves minutes rather than seconds

---

## Migration Guide

### Switching from `full` to `node_modules-only`
Expand Down
48 changes: 41 additions & 7 deletions shared-actions/setup-node-with-cache/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ inputs:
description: 'Disable restore-keys to avoid restoring stale caches (forces exact key match)'
required: false
default: 'false'
lockfile-only-cache-key:
description: >-
Derive the dependency cache key from the lockfile alone, ignoring package.json
files. Defaults to false, which preserves the existing behaviour of hashing the
lockfile plus every package.json.

In a workspace repo with many packages, hashing every package.json means edits
that cannot change what gets installed (a description, a version bump, a script)
still invalidate the whole cache. Keying on the lockfile alone is safe because it
already pins the fully resolved tree, and yarn/pnpm update it whenever a manifest
edit actually changes resolution.
required: false
default: 'false'
package-manager:
description: 'Package manager to use: "yarn" or "pnpm"'
required: false
Expand Down Expand Up @@ -181,22 +194,43 @@ runs:

echo "key-suffix=-$CACHE_MODE" >> $GITHUB_OUTPUT

# hashFiles() cannot take a variable glob, so both candidate hashes are computed
# here and the requested one is exported for the cache steps below to consume.
- name: Compute dependency cache hash
id: dep-hash
shell: bash
env:
HASH_WITH_MANIFESTS_YARN: ${{ hashFiles('**/yarn.lock', '**/package.json') }}
HASH_LOCKFILE_ONLY_YARN: ${{ hashFiles('**/yarn.lock') }}
HASH_WITH_MANIFESTS_PNPM: ${{ hashFiles('**/pnpm-lock.yaml', '**/package.json') }}
HASH_LOCKFILE_ONLY_PNPM: ${{ hashFiles('**/pnpm-lock.yaml') }}
run: |
if [ "${{ inputs.lockfile-only-cache-key }}" == "true" ]; then
echo "yarn=$HASH_LOCKFILE_ONLY_YARN" >> $GITHUB_OUTPUT
echo "pnpm=$HASH_LOCKFILE_ONLY_PNPM" >> $GITHUB_OUTPUT
echo "Cache key source: lockfile only"
else
echo "yarn=$HASH_WITH_MANIFESTS_YARN" >> $GITHUB_OUTPUT
echo "pnpm=$HASH_WITH_MANIFESTS_PNPM" >> $GITHUB_OUTPUT
echo "Cache key source: lockfile + all package.json (default)"
fi

- name: Get yarn cache
if: ${{ !env.ACT && inputs.package-manager != 'pnpm' }}
uses: actions/cache@v6
id: yarn-cache
with:
path: ${{ steps.cache-paths.outputs.paths }}
key: ${{ runner.os }}-${{ runner.arch }}-yarn-${{ hashFiles('**/yarn.lock', '**/package.json') }}${{ steps.cache-paths.outputs.key-suffix }}
key: ${{ runner.os }}-${{ runner.arch }}-yarn-${{ steps.dep-hash.outputs.yarn }}${{ steps.cache-paths.outputs.key-suffix }}
restore-keys: ${{ inputs.disable-restore-keys != 'true' && format('{0}-{1}-yarn-', runner.os, runner.arch) || '' }}

- name: Get pnpm cache
if: ${{ !env.ACT && inputs.package-manager == 'pnpm' }}
uses: actions/cache@v6
id: pnpm-cache
with:
path: ${{ steps.cache-paths-pnpm.outputs.paths }}
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml', '**/package.json') }}${{ steps.cache-paths-pnpm.outputs.key-suffix }}
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ steps.dep-hash.outputs.pnpm }}${{ steps.cache-paths-pnpm.outputs.key-suffix }}
restore-keys: ${{ inputs.disable-restore-keys != 'true' && format('{0}-{1}-pnpm-', runner.os, runner.arch) || '' }}

- name: Debug cache contents
Expand All @@ -210,9 +244,9 @@ runs:

echo "=== 🔍 Cache Debug Info ==="
if [ "$PM" == "pnpm" ]; then
echo "Cache key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml', '**/package.json') }}"
echo "Cache key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ steps.dep-hash.outputs.pnpm }}"
else
echo "Cache key: ${{ runner.os }}-${{ runner.arch }}-yarn-${{ hashFiles('**/yarn.lock', '**/package.json') }}"
echo "Cache key: ${{ runner.os }}-${{ runner.arch }}-yarn-${{ steps.dep-hash.outputs.yarn }}"
fi
echo ""

Expand Down Expand Up @@ -414,11 +448,11 @@ runs:

if [ "$PM" == "pnpm" ]; then
CACHE_HIT="${{ steps.pnpm-cache.outputs.cache-hit }}"
CACHE_KEY="${{ runner.os }}-${{ runner.arch }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml', '**/package.json') }}"
CACHE_KEY="${{ runner.os }}-${{ runner.arch }}-pnpm-${{ steps.dep-hash.outputs.pnpm }}"
INSTALL_CMD="pnpm install"
else
CACHE_HIT="${{ steps.yarn-cache.outputs.cache-hit }}"
CACHE_KEY="${{ runner.os }}-${{ runner.arch }}-yarn-${{ hashFiles('**/yarn.lock', '**/package.json') }}"
CACHE_KEY="${{ runner.os }}-${{ runner.arch }}-yarn-${{ steps.dep-hash.outputs.yarn }}"
INSTALL_CMD="yarn install"
fi

Expand Down